k-skill/scripts/test_geeknews_search.py
Jeffrey (Dongkyu) Kim 43e8625986 Add a repeatable GeekNews lookup path without unofficial APIs
Issue #108 needs a dedicated read-only skill that can browse,
search, and inspect GeekNews posts using the public feed alone.
The implementation adds a fixture-backed Atom helper, skill/docs
surfaces, and install/README wiring, then verifies the helper
against the live GeekNews feed.

Constraint: Must stay RSS-first and avoid new dependencies or unofficial APIs
Constraint: Skill development requires syncing the skill into ~/.claude/skills and ~/.agents/skills during verification
Rejected: Fetch article pages directly for v1 | expands scope beyond the approved RSS-driven workflow
Rejected: Use XML parser modules | current python3 environment has expat issues, so regex + HTML parsing is safer here
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the root helper and geeknews-search/scripts copy behaviorally identical because the installed skill must remain self-contained
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_geeknews_search; node --test scripts/skill-docs.test.js; python3 scripts/geeknews_search.py list --limit 3; python3 scripts/geeknews_search.py search --query Claude --limit 3; python3 scripts/geeknews_search.py detail --id 28439; npm run ci
Not-tested: Non-default feed mirrors or future Atom schema changes beyond the current public GeekNews feed shape
Related: Issue #108
2026-04-13 00:16:35 +09:00

128 lines
5.4 KiB
Python

import contextlib
import io
import json
import os
from pathlib import Path
import unittest
from scripts.geeknews_search import (
GeekNewsFeed,
build_detail_payload,
build_list_payload,
build_search_payload,
get_item_detail,
list_items,
load_feed,
main,
search_items,
)
FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "geeknews-feed.xml"
class GeekNewsFeedParseTest(unittest.TestCase):
def test_load_feed_parses_atom_entries_into_normalized_items(self):
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
self.assertIsInstance(feed, GeekNewsFeed)
self.assertEqual(feed.title, "GeekNews - 개발/기술/스타트업 뉴스 서비스")
self.assertEqual(feed.updated, "2026-04-12T22:53:56+09:00")
self.assertEqual(feed.home_url, "https://news.hada.io")
self.assertEqual(feed.feed_url, "https://news.hada.io/rss/news")
self.assertEqual(len(feed.items), 3)
first = feed.items[0]
self.assertEqual(first.title, "Ask GN: 기억이 안나는 웹사이트를 찾고 있습니다.")
self.assertEqual(first.link, "https://news.hada.io/topic?id=28441")
self.assertEqual(first.id, "https://news.hada.io/topic?id=28441")
self.assertEqual(first.author_name, "princox")
self.assertEqual(first.author_url, "https://news.hada.io/user/princox")
self.assertEqual(first.published, "2026-04-12T22:53:56+09:00")
self.assertIn("시각화 사이트", first.summary)
self.assertNotIn("<p>", first.summary)
self.assertIn("<p>", first.content_html)
def test_list_items_keeps_feed_order_and_applies_limit(self):
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
items = list_items(feed, limit=2)
self.assertEqual([item.id for item in items], [
"https://news.hada.io/topic?id=28441",
"https://news.hada.io/topic?id=28440",
])
def test_search_items_matches_title_summary_and_author_case_insensitively(self):
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
ai_matches = search_items(feed, query="agent", limit=5)
author_matches = search_items(feed, query="WORKDRIVER", limit=5)
self.assertEqual([item.id for item in ai_matches], ["https://news.hada.io/topic?id=28440"])
self.assertEqual([item.id for item in author_matches], ["https://news.hada.io/topic?id=28439"])
def test_get_item_detail_resolves_by_id_or_link_and_errors_cleanly(self):
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
item = get_item_detail(feed, "https://news.hada.io/topic?id=28439")
same_item = get_item_detail(feed, "28439")
self.assertEqual(item.title, "Show GN: [GN] 비개발자 + Claude로 프로덕션 운영 238일 — 무엇이 됐고 무엇이 안 됐나?")
self.assertEqual(same_item.id, item.id)
with self.assertRaisesRegex(LookupError, "No GeekNews entry matched"):
get_item_detail(feed, "missing-topic")
class GeekNewsPayloadShapeTest(unittest.TestCase):
def test_list_search_and_detail_payloads_have_stable_json_shape(self):
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
list_payload = build_list_payload(feed, limit=2)
search_payload = build_search_payload(feed, query="claude", limit=5)
detail_payload = build_detail_payload(feed, lookup="28439")
self.assertEqual(list_payload["source"]["title"], feed.title)
self.assertEqual(list_payload["count"], 2)
self.assertEqual(search_payload["query"], "claude")
self.assertEqual(search_payload["count"], 1)
self.assertEqual(detail_payload["item"]["id"], "https://news.hada.io/topic?id=28439")
self.assertIn("summary", detail_payload["item"])
self.assertIn("content_html", detail_payload["item"])
class GeekNewsCliShapeTest(unittest.TestCase):
def test_cli_prints_json_for_each_subcommand(self):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
main(["list", "--feed-file", str(FIXTURE_PATH), "--limit", "2"])
listed = json.loads(stdout.getvalue())
self.assertEqual(listed["count"], 2)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
main(["search", "--feed-file", str(FIXTURE_PATH), "--query", "claude"])
searched = json.loads(stdout.getvalue())
self.assertEqual(searched["count"], 1)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
main(["detail", "--feed-file", str(FIXTURE_PATH), "--id", "28439"])
detail = json.loads(stdout.getvalue())
self.assertEqual(detail["item"]["author_name"], "workdriver")
def test_helper_scripts_are_executable_python_entrypoints(self):
repo_root = Path(__file__).resolve().parent.parent
for helper in (
repo_root / "scripts" / "geeknews_search.py",
repo_root / "geeknews-search" / "scripts" / "geeknews_search.py",
):
with self.subTest(helper=helper):
self.assertTrue(os.access(helper, os.X_OK), f"{helper} should be executable")
self.assertTrue(
helper.read_text(encoding="utf-8").startswith("#!/usr/bin/env python3\n"),
f"{helper} should start with a Python shebang",
)
if __name__ == "__main__":
unittest.main()