|
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='<img src="http://img.test/a.png">')
|
|
|
|
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 = (
|
|
'<img src="http://img.test/a.png">'
|
|
"<img src='http://img.test/a.png'>"
|
|
'<img src="http://img.test/b.svg">'
|
|
'<img src="/relative.png">'
|
|
)
|
|
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
|