import asyncio import httpx from devplacepy.services import news as news_mod from devplacepy.services.news import NewsService, _extract_grade, _get_ai_key, _get_article_images from devplacepy.database import get_table from devplacepy.utils import generate_uid API_URL = "http://news.test/api" AI_URL = "http://ai.test/v1/chat" LINK_HIGH = "http://news.test/high" LINK_LOW = "http://news.test/low" class FakeResp: def __init__(self, json_data=None, text="", status=200): self._json = json_data self.text = text self.status_code = status def raise_for_status(self): if self.status_code >= 400: raise httpx.HTTPError(f"status {self.status_code}") def json(self): return self._json class FakeClient: def __init__(self, articles): self.articles = articles async def __aenter__(self): return self async def __aexit__(self, *exc): return False async def get(self, url, timeout=None): if url == API_URL: return FakeResp(json_data={"articles": self.articles}) return FakeResp(text='') async def post(self, url, json=None, headers=None, timeout=None): prompt = json["messages"][0]["content"] if "FailArticle" in prompt: return FakeResp(status=500, text="err") if "EmptyArticle" in prompt: return FakeResp(json_data={"choices": [{"message": {"content": ""}}]}) if "BadArticle" in prompt: return FakeResp(json_data={"choices": [{"message": {"content": "no number"}}]}) grade = "9" if "HighArticle" in prompt else "3" return FakeResp(json_data={"choices": [{"message": {"content": grade}}]}) class FailingApiClient(FakeClient): async def get(self, url, timeout=None): if url == API_URL: raise httpx.HTTPError("api down") return FakeResp(text="") def _settings_stub(threshold="7"): def fake_get_setting(key, default=None): return { "news_api_url": API_URL, "news_ai_url": AI_URL, "news_ai_model": "test-model", "news_grade_threshold": threshold, "news_ai_key": "", }.get(key, default) return fake_get_setting def test_extract_grade_parsing(): assert _extract_grade("8") == 8 assert _extract_grade("Grade: 9") == 9 assert _extract_grade("Score is 7/10") == 7 assert _extract_grade("0") is None assert _extract_grade("11") is None assert _extract_grade("not a number") is None def test_get_ai_key_env_precedence(monkeypatch): monkeypatch.setattr(news_mod, "get_setting", _settings_stub()) monkeypatch.setenv("NEWS_AI_KEY", "primary") assert _get_ai_key() == "primary" def test_get_ai_key_setting_fallback(monkeypatch): monkeypatch.delenv("NEWS_AI_KEY", raising=False) monkeypatch.setattr(news_mod, "get_setting", lambda key, default=None: "from-setting" if key == "news_ai_key" else default) assert _get_ai_key() == "from-setting" def test_get_ai_key_openrouter_fallback(monkeypatch): monkeypatch.delenv("NEWS_AI_KEY", raising=False) monkeypatch.setattr(news_mod, "get_setting", _settings_stub()) monkeypatch.setenv("OPENROUTER_API_KEY", "router-key") monkeypatch.delenv("OPENAI_API_KEY", raising=False) assert _get_ai_key() == "router-key" def test_get_ai_key_openai_fallback(monkeypatch): monkeypatch.delenv("NEWS_AI_KEY", raising=False) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) monkeypatch.setattr(news_mod, "get_setting", _settings_stub()) monkeypatch.setenv("OPENAI_API_KEY", "openai-key") assert _get_ai_key() == "openai-key" def test_get_ai_key_empty_when_unset(monkeypatch): monkeypatch.delenv("NEWS_AI_KEY", raising=False) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.setattr(news_mod, "get_setting", _settings_stub()) assert _get_ai_key() == "" def test_grade_article_empty_content_returns_none(local_db, monkeypatch): monkeypatch.setattr(news_mod, "get_setting", _settings_stub()) grade = asyncio.run(NewsService()._grade_article( {"title": "EmptyArticle", "description": "d", "content": "c"}, AI_URL, "m", FakeClient([]))) assert grade is None def test_grade_article_unparseable_returns_none(local_db, monkeypatch): monkeypatch.setattr(news_mod, "get_setting", _settings_stub()) grade = asyncio.run(NewsService()._grade_article( {"title": "BadArticle", "description": "d", "content": "c"}, AI_URL, "m", FakeClient([]))) assert grade is None def test_run_once_handles_api_failure(local_db, monkeypatch): monkeypatch.setattr(news_mod, "get_setting", _settings_stub()) monkeypatch.setattr(news_mod.httpx, "AsyncClient", lambda *a, **k: FailingApiClient([])) asyncio.run(NewsService().run_once()) def test_run_once_updates_existing_news_row(local_db, monkeypatch): external_id = f"news-{generate_uid()}" existing_uid = generate_uid() get_table("news").insert({ "uid": existing_uid, "external_id": external_id, "slug": "", "title": "Old Title", "status": "draft", "grade": 0, "synced_at": "2020-01-01", }) articles = [{"guid": external_id, "title": "HighArticle", "description": "d", "content": "c", "link": "", "feed_name": "Feed", "author": "A", "published": "2026-01-01"}] monkeypatch.setattr(news_mod, "get_setting", _settings_stub(threshold="7")) monkeypatch.setattr(news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient(articles)) asyncio.run(NewsService().run_once()) row = get_table("news").find_one(uid=existing_uid) assert row["title"] == "HighArticle" assert row["status"] == "published" assert get_table("news").count(external_id=external_id) == 1 def test_get_article_images_filters_and_dedupes(): html = ( '' "" '' '' ) client = FakeClient([]) async def fake_get(url, timeout=None): return FakeResp(text=html) client.get = fake_get images = asyncio.run(_get_article_images("http://news.test/page", client)) assert [img["url"] for img in images] == ["http://img.test/a.png"] def test_get_article_images_network_error_returns_empty(): client = FakeClient([]) async def failing_get(url, timeout=None): raise httpx.HTTPError("down") client.get = failing_get assert asyncio.run(_get_article_images("http://news.test/page", client)) == [] def test_run_once_publishes_grades_and_is_idempotent(local_db, monkeypatch): g_high, g_low, g_fail = (f"news-{generate_uid()}" for _ in range(3)) articles = [ {"guid": g_high, "title": "HighArticle", "description": "d", "content": "c", "link": LINK_HIGH, "feed_name": "Feed", "author": "A", "published": "2026-01-01"}, {"guid": g_low, "title": "LowArticle", "description": "d", "content": "c", "link": LINK_LOW, "feed_name": "Feed", "author": "A", "published": "2026-01-01"}, {"guid": g_fail, "title": "FailArticle", "description": "d", "content": "c", "link": "", "feed_name": "Feed", "author": "A", "published": "2026-01-01"}, {"guid": "", "title": "NoGuid", "description": "d", "content": "c", "link": "", "feed_name": "Feed", "author": "A", "published": "2026-01-01"}, ] monkeypatch.setattr(news_mod, "get_setting", _settings_stub(threshold="7")) monkeypatch.setattr(news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient(articles)) asyncio.run(NewsService().run_once()) news = get_table("news") sync = get_table("news_sync") assert news.find_one(external_id=g_high)["status"] == "published" assert news.find_one(external_id=g_high)["grade"] == 9 assert news.find_one(external_id=g_low)["status"] == "draft" assert news.find_one(external_id=g_low)["grade"] == 3 fail_row = news.find_one(external_id=g_fail) assert fail_row["status"] == "draft" assert fail_row["grade"] == 0 assert sync.find_one(external_id=g_fail)["status"] == "grading_failed" assert sync.find_one(external_id=g_high)["status"] == "graded" asyncio.run(NewsService().run_once()) assert news.count(external_id=g_high) == 1 assert news.count(external_id=g_fail) == 1