318 lines
14 KiB
Python
318 lines
14 KiB
Python
"""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"""<?xml version="1.0"?><rss><channel>
|
|
<item><title>Game X released</title><link>https://ex.com/x</link></item>
|
|
<item><title>Patch Y notes</title><link>https://ex.com/y</link></item>
|
|
</channel></rss>"""
|
|
|
|
ATOM = b"""<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom">
|
|
<entry><title>Atom headline</title><link href="https://ex.com/a"/></entry>
|
|
</feed>"""
|
|
|
|
|
|
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"<not xml", "bad"), [])
|
|
self.assertEqual(parse_feed(b"", "empty"), [])
|
|
|
|
|
|
class TestDigest(unittest.TestCase):
|
|
def test_sanitized_and_capped(self):
|
|
"""NEWS-02: headlines sanitized, item count capped."""
|
|
items = [{"title": "@everyone big news \x00", "link": "", "source": "S"} for _ in range(20)]
|
|
digest = render_digest(items, max_items=5)
|
|
self.assertEqual(digest.count("\n"), 4) # 5 lines
|
|
self.assertNotIn("@everyone", digest)
|
|
self.assertNotIn("\x00", digest)
|
|
|
|
|
|
class TestCollect(unittest.IsolatedAsyncioTestCase):
|
|
async def test_ssrf_skip_and_dedup(self):
|
|
"""NEWS-03: guarded feed skipped, dup titles dropped, bad fetch survived."""
|
|
|
|
def guard(url):
|
|
return "refused" if "internal" in url else None
|
|
|
|
async def fetch(url):
|
|
if "boom" in url:
|
|
raise ValueError("boom")
|
|
return RSS # same content from two feeds -> 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"""<?xml version="1.0"?><rss><channel>
|
|
<item><title>Storm hits coast</title><link>https://ex.com/s</link>
|
|
<description><p>Heavy <b>wind</b> expected</p></description></item>
|
|
</channel></rss>"""
|
|
|
|
ATOM_SUM = b"""<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom">
|
|
<entry><title>Atom T</title><link href="https://ex.com/a"/><summary>Short gist here</summary></entry>
|
|
</feed>"""
|
|
|
|
|
|
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)
|