"""Unit coverage for SPEC-014 Codex Mechanicus search (CDX-01..06).""" import json import unittest from unittest.mock import AsyncMock, patch from fjerkroa_bot.codex import CODEX_SEARCH_TOOL, CodexSearch from fjerkroa_bot.openai_responder import OpenAIResponder CONFIG = {"openai-token": "t", "model": "m", "system": "s", "history-limit": 5} INDEX = { "items": [ { "id": "doctrine-heretek", "collection": "doctrines", "url": "/en/codex/doctrines/doctrine-heretek/", "title": "Heretek — Doctrine of the Tech-Heretic", "summary": "The label the Cult Mechanicus stamps on Tech-Priests who pursue forbidden sciences.", "body": "xenotech, sentient machines, Warp-touched archeotech", }, { "id": "doctrine-heretek", "collection": "doctrines", "url": "/de/codex/doctrines/doctrine-heretek/", "title": "Heretek — Doktrin des Techketzers", "summary": "Das Etikett des Kultes Mechanicus fuer Techpriester verbotener Wissenschaften.", "body": "Xenotech, empfindungsfaehige Maschinen", }, { "id": "forge-stygies", "collection": "forges", "url": "/en/codex/forges/forge-stygies/", "title": "Stygies VIII", "summary": "A forge world of shrouded reputation.", "body": "The forge fields many Skitarii legions.", }, ] } def _reader(cfg): reader = CodexSearch(lambda: cfg) return reader class TestToolOffered(unittest.TestCase): def test_tool_present_only_when_enabled(self): """CDX-01: codex_search appears only with enable-codex.""" off = OpenAIResponder(CONFIG, "chat") self.assertNotIn("codex_search", [f["name"] for f in off._available_tools()]) on = OpenAIResponder(dict(CONFIG, **{"enable-codex": True}), "chat") self.assertIn("codex_search", [f["name"] for f in on._available_tools()]) self.assertEqual(CODEX_SEARCH_TOOL["name"], "codex_search") class TestIndexGuardAndCache(unittest.IsolatedAsyncioTestCase): async def test_internal_index_url_refused(self): """CDX-02: an index URL on a private address is refused before any fetch.""" reader = _reader({"enable-codex": True, "codex-index-url": "http://127.0.0.1/search-index.json"}) result = await reader.search("heretek") self.assertIn("error", result) async def test_index_cached_within_ttl(self): """CDX-02: a second search inside the TTL does not re-fetch the index.""" reader = _reader({"enable-codex": True, "codex-cache-ttl": 9999}) raw = json.dumps(INDEX).encode() calls = [0] class FakeResp: status = 200 async def __aenter__(self): return self async def __aexit__(self, *a): return False def raise_for_status(self): pass class FakeSession: def get(self, url): calls[0] += 1 return FakeResp() async def __aenter__(self): return self async def __aexit__(self, *a): return False with patch("fjerkroa_bot.codex.read_capped", new=AsyncMock(return_value=raw)): with patch("fjerkroa_bot.codex.guard_url", return_value=None): with patch("fjerkroa_bot.codex.aiohttp.ClientSession", return_value=FakeSession()): first = await reader.search("heretek") second = await reader.search("stygies") self.assertEqual(calls[0], 1) # fetched once, served from cache the second time self.assertTrue(first["results"] and second["results"]) class TestRankingAndLang(unittest.IsolatedAsyncioTestCase): async def _search(self, cfg, query, lang="en"): reader = _reader(dict({"enable-codex": True}, **cfg)) reader._cache = INDEX["items"] reader._fetched_at = 1e18 # far future: never expires in test with patch("fjerkroa_bot.codex.time.monotonic", return_value=1e18): return await reader.search(query, lang) async def test_title_hit_outranks_body_hit(self): """CDX-03: a title match ranks above a body-only match.""" result = await self._search({}, "heretek") self.assertEqual(result["results"][0]["title"].split(" ")[0], "Heretek") self.assertTrue(result["results"][0]["url"].startswith("https://binaric.tech/en/")) async def test_lang_filter_selects_language(self): """CDX-04: lang=de returns the German inscription.""" result = await self._search({}, "heretek", lang="de") self.assertTrue(all("/de/" in r["url"] for r in result["results"])) self.assertIn("Techketzer", result["results"][0]["title"]) async def test_lang_fallback_when_absent(self): """CDX-04: a tongue with no match falls back to all tongues, not empty.""" result = await self._search({}, "stygies", lang="uk") # only en/de exist self.assertTrue(result["results"]) self.assertEqual(result["results"][0]["title"], "Stygies VIII") class TestSanitizeAndFailure(unittest.IsolatedAsyncioTestCase): async def test_result_sanitized_and_capped(self): """CDX-05: title/summary are @-neutralized and length-capped.""" reader = _reader({"enable-codex": True, "codex-summary-chars": 40}) reader._cache = [{"collection": "x", "url": "/en/x/", "title": "@everyone hi", "summary": "@here " + "y" * 500, "body": "hit"}] reader._fetched_at = 1e18 with patch("fjerkroa_bot.codex.time.monotonic", return_value=1e18): result = await reader.search("hit") top = result["results"][0] self.assertNotIn("@everyone", top["title"]) self.assertNotIn("@here", top["summary"]) self.assertLessEqual(len(top["summary"]), 40) async def test_index_failure_returns_error(self): """CDX-05: a broken index returns an error dict, never raises.""" reader = _reader({"enable-codex": True}) with patch.object(reader, "_load_index", new=AsyncMock(side_effect=ValueError("boom"))): result = await reader.search("heretek") self.assertIn("error", result) class TestPerUserCap(unittest.IsolatedAsyncioTestCase): async def test_dispatch_caps_searches(self): """CDX-06: over codex-daily-per-user, codex_search refuses without searching.""" responder = OpenAIResponder(dict(CONFIG, **{"enable-codex": True, "codex-daily-per-user": 2}), "chat") responder.codex.search = AsyncMock(return_value={"query": "x", "results": []}) for _ in range(2): await responder._dispatch_tool("codex_search", {"query": "heretek"}, "magos") blocked = await responder._dispatch_tool("codex_search", {"query": "heretek"}, "magos") self.assertIn("error", blocked) self.assertEqual(responder.codex.search.await_count, 2)