"""Unit coverage for SPEC-013 news digest + memory + tool (NEWS-01..12).""" import tempfile import unittest from pathlib import Path from unittest.mock import AsyncMock from fjerkroa_bot.news import ( GET_NEWS_TOOL, NewsFetcher, NewsPoster, load_seen, parse_feed, query_news, render_digest, save_seen, ) from fjerkroa_bot.openai_responder import OpenAIResponder from fjerkroa_bot.persistence import PersistentStore CONFIG = {"openai-token": "t", "model": "m", "system": "s", "history-limit": 5} RSS = b""" Game X releasedhttps://ex.com/x Patch Y noteshttps://ex.com/y """ ATOM = b""" Atom headline """ class TestParse(unittest.TestCase): def test_rss(self): """NEWS-01: RSS items parsed with title + link.""" items = parse_feed(RSS, "Src") self.assertEqual([i["title"] for i in items], ["Game X released", "Patch Y notes"]) self.assertEqual(items[0]["link"], "https://ex.com/x") self.assertEqual(items[0]["source"], "Src") def test_atom(self): """NEWS-01: Atom entries parsed with href link.""" items = parse_feed(ATOM, "A") self.assertEqual(items[0]["title"], "Atom headline") self.assertEqual(items[0]["link"], "https://ex.com/a") def test_malformed_never_raises(self): """NEWS-01: garbage XML returns [] without raising.""" self.assertEqual(parse_feed(b" dedup fetcher = NewsFetcher(guard, fetch) feeds = [ ("https://a.com/feed", "A"), ("https://internal/feed", "Internal"), # SSRF-skipped ("https://boom.com/feed", "Boom"), # fetch fails ("https://b.com/feed", "B"), # same RSS -> dup titles dropped ] items = await fetcher.collect(feeds, per_feed=5) titles = [i["title"] for i in items] self.assertEqual(titles, ["Game X released", "Patch Y notes"]) # deduped, internal+boom skipped async def test_per_feed_limit(self): """NEWS-03: per-feed cap honored.""" fetcher = NewsFetcher(lambda u: None, AsyncMock(return_value=RSS)) items = await fetcher.collect([("https://a.com", "A")], per_feed=1) self.assertEqual(len(items), 1) class TestPoster(unittest.IsolatedAsyncioTestCase): def poster(self, posts): async def fetch(url): return RSS async def post(hook, content): posts.append((hook, content)) return NewsPoster(lambda u: None, fetch, post) async def test_posts_unseen_then_dedups(self): """NEWS-04: unseen items post to the mapped webhook; re-run posts nothing.""" posts = [] poster = self.poster(posts) feeds = [("https://a.com/feed", "PS", "news")] hooks = {"news": "https://discord.com/api/webhooks/x"} posted, seen = await poster.run_post(feeds, hooks, set(), per_feed=5, max_per_run=8, seed_only=False) self.assertEqual(posted, 2) self.assertIn("PS", posts[0][1]) self.assertIn("https://discord.com/api/webhooks/x", posts[0][0]) # re-run with the accumulated seen -> nothing new posts.clear() posted2, _ = await poster.run_post(feeds, hooks, seen, per_feed=5, max_per_run=8, seed_only=False) self.assertEqual(posted2, 0) self.assertEqual(posts, []) async def test_seed_run_posts_nothing(self): """NEWS-05: seed_only marks items seen without posting.""" posts = [] poster = self.poster(posts) feeds = [("https://a.com/feed", "PS", "news")] posted, seen = await poster.run_post(feeds, {"news": "h"}, set(), 5, 8, seed_only=True) self.assertEqual(posted, 0) self.assertEqual(posts, []) self.assertEqual(len(seen), 2) # both marked seen async def test_max_per_run_caps(self): """NEWS-05: max-per-run caps posts; extras stay seen (not re-posted next run).""" posts = [] poster = self.poster(posts) feeds = [("https://a.com/feed", "PS", "news")] posted, seen = await poster.run_post(feeds, {"news": "h"}, set(), per_feed=5, max_per_run=1, seed_only=False) self.assertEqual(posted, 1) self.assertEqual(len(seen), 2) # both seen, only one posted async def test_failures_survived(self): """NEWS-06: SSRF-skip, fetch fail, missing webhook, post error each survive.""" posts = [] async def fetch(url): if "boom" in url: raise ValueError("boom") return RSS async def post(hook, content): if hook == "bad": raise RuntimeError("post failed") posts.append((hook, content)) def guard(url): return "refused" if "internal" in url else None poster = NewsPoster(guard, fetch, post) feeds = [ ("https://internal/feed", "I", "news"), # SSRF-skipped ("https://boom.com/feed", "B", "news"), # fetch fails ("https://ok.com/feed", "OK", "nowhere"), # no webhook for channel ("https://ok2.com/feed", "OK2", "news"), # webhook raises ] posted, seen = await poster.run_post(feeds, {"news": "bad"}, set(), 5, 8, seed_only=False) self.assertEqual(posted, 0) # everything failed/skipped, no crash class TestSeenState(unittest.TestCase): def test_roundtrip_and_seed_detection(self): """NEWS-05: missing state -> (empty, existed=False); saved state reloads.""" import tempfile from pathlib import Path with tempfile.TemporaryDirectory() as tmp: path = str(Path(tmp) / "state.json") seen, existed = load_seen(path) self.assertEqual((seen, existed), (set(), False)) save_seen(path, {"a", "b", "c"}, cap=5000) reloaded, existed2 = load_seen(path) self.assertEqual(reloaded, {"a", "b", "c"}) self.assertTrue(existed2) def test_cap_bounds_state(self): """NEWS-05: save keeps at most `cap` keys.""" import json with tempfile.TemporaryDirectory() as tmp: path = str(Path(tmp) / "state.json") save_seen(path, {f"k{i}" for i in range(100)}, cap=10) self.assertEqual(len(json.load(open(path))), 10) RSS_DESC = b""" Storm hits coasthttps://ex.com/s <p>Heavy <b>wind</b> expected</p> """ ATOM_SUM = b""" Atom TShort gist here """ class TestSummaries(unittest.TestCase): def test_rss_description_stripped(self): """NEWS-07: RSS description parsed, HTML stripped, entities unescaped, whitespace collapsed.""" items = parse_feed(RSS_DESC, "S") self.assertEqual(items[0]["summary"], "Heavy wind expected") def test_atom_summary(self): """NEWS-07: Atom summary collapsed to clean text.""" items = parse_feed(ATOM_SUM, "A") self.assertEqual(items[0]["summary"], "Short gist here") def test_missing_description_is_empty(self): """NEWS-07: no description -> empty summary, never an error.""" self.assertEqual(parse_feed(RSS, "S")[0]["summary"], "") def test_digest_carries_summary(self): """NEWS-08: digest appends the sanitized capped summary; zero cap = title only.""" items = [{"title": "T", "link": "https://ex.com/x", "source": "NRK", "summary": "the gist of it"}] digest = render_digest(items, 10, 100) self.assertIn("[NRK]", digest) self.assertIn("the gist of it", digest) self.assertNotIn("the gist", render_digest(items, 10, 0)) # zero cap -> title only class NewsStoreBase(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.store = PersistentStore(Path(self.tmp.name) / "bot.db") class TestNewsStore(NewsStoreBase): def test_dedup_and_rolling_window(self): """NEWS-09: items deduped by link; prune keeps the newest N.""" first = [ {"title": "A", "link": "L1", "source": "S", "summary": "sa"}, {"title": "B", "link": "L2", "source": "S", "summary": "sb"}, ] self.assertEqual(self.store.add_news_items(first), 2) self.assertEqual(self.store.add_news_items([dict(first[0])]), 0) # dup link ignored self.assertEqual(self.store.news_count(), 2) self.store.prune_news(1) self.assertEqual(self.store.news_count(), 1) self.assertEqual(self.store.recent_news(5)[0]["title"], "B") # newest survives def test_dedup_by_title_when_no_link(self): """NEWS-09: linkless items dedup on title.""" self.store.add_news_items([{"title": "Same", "link": "", "source": "S", "summary": ""}]) self.store.add_news_items([{"title": "Same", "link": "", "source": "S", "summary": ""}]) self.assertEqual(self.store.news_count(), 1) class TestQueryNews(NewsStoreBase): def seed(self): self.store.add_news_items( [ {"title": "Nordland storm", "link": "L1", "source": "Nordland", "summary": "strong wind on the coast"}, {"title": "Oslo budget", "link": "L2", "source": "NRK", "summary": "@everyone spending plan"}, {"title": "Sport result", "link": "L3", "source": "Sport", "summary": "the match ended"}, ] ) def test_topic_filter(self): """NEWS-11: topic keywords must appear in title or summary.""" self.seed() res = query_news(self.store, topic="storm") self.assertEqual([r["title"] for r in res["results"]], ["Nordland storm"]) def test_topic_matches_source_label(self): """NEWS-11: topic also matches the source label, so 'Nordland' finds regional items.""" self.store.add_news_items([{"title": "Ferry delayed", "link": "LX", "source": "Nordland", "summary": "boat late"}]) res = query_news(self.store, topic="Nordland") self.assertTrue(any(r["link"] == "LX" for r in res["results"])) # matched via source, not title/summary def test_source_filter_and_sanitize(self): """NEWS-11: source narrows results; title/summary are sanitized.""" self.seed() res = query_news(self.store, source="NRK") self.assertTrue(res["results"] and all(r["source"] == "NRK" for r in res["results"])) self.assertNotIn("@everyone", res["results"][0]["summary"]) def test_limit_clamped_and_no_store(self): """NEWS-11: limit clamps to 1..30; a missing store returns an error.""" self.seed() self.assertLessEqual(len(query_news(self.store, limit=999)["results"]), 30) self.assertGreaterEqual(len(query_news(self.store, limit=0)["results"]), 1) self.assertIn("error", query_news(None)) class TestNewsTool(unittest.IsolatedAsyncioTestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) def _responder(self, **extra): cfg = dict(CONFIG, **{"history-directory": self.tmp.name}, **extra) return OpenAIResponder(cfg, "chat") def test_tool_offered_needs_flag_and_store(self): """NEWS-10: get_news offered only with enable-news-tool AND a store.""" no_store = OpenAIResponder(dict(CONFIG, **{"enable-news-tool": True}), "chat") self.assertIsNone(no_store.store) self.assertNotIn("get_news", [f["name"] for f in no_store._available_tools()]) flag_off = self._responder() self.assertNotIn("get_news", [f["name"] for f in flag_off._available_tools()]) on = self._responder(**{"enable-news-tool": True}) self.assertIn("get_news", [f["name"] for f in on._available_tools()]) self.assertEqual(GET_NEWS_TOOL["name"], "get_news") async def test_dispatch_caps_news(self): """NEWS-12: over news-daily-per-user, get_news refuses without querying.""" responder = self._responder(**{"enable-news-tool": True, "news-daily-per-user": 2}) responder.store.add_news_items([{"title": "x", "link": "l", "source": "s", "summary": "y"}]) for _ in range(2): self.assertIn("results", await responder._dispatch_tool("get_news", {}, "alice")) blocked = await responder._dispatch_tool("get_news", {}, "alice") self.assertIn("error", blocked)