news webhook posting mode: replaces py3.8 getnews (bounded state, seed-on-first-run, ssrf-guarded)
This commit is contained in:
+101
-1
@@ -3,7 +3,7 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from fjerkroa_bot.news import NewsFetcher, parse_feed, render_digest
|
||||
from fjerkroa_bot.news import NewsFetcher, NewsPoster, load_seen, parse_feed, render_digest, save_seen
|
||||
|
||||
RSS = b"""<?xml version="1.0"?><rss><channel>
|
||||
<item><title>Game X released</title><link>https://ex.com/x</link></item>
|
||||
@@ -73,3 +73,103 @@ class TestCollect(unittest.IsolatedAsyncioTestCase):
|
||||
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
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user