chore: reorganize test files into domain-specific subdirectories under tests/
Split the monolithic test directory into three tiers (unit, api, e2e) with a path-mirroring directory structure. Added corresponding Makefile targets (test-unit, test-api, test-e2e) and updated all documentation references (CLAUDE.md, README.md, testing-cicd.html, testing-framework.html, testing-make.html) to reflect the new layout and naming conventions.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import httpx
|
||||
from devplacepy.services import news as news_mod
|
||||
from devplacepy.services import base as base_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
|
||||
from tests.conftest import run_async
|
||||
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_news_service:
|
||||
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_news_service:
|
||||
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_news_service(json_data={"articles": self.articles})
|
||||
return FakeResp_news_service(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_news_service(status=500, text="err")
|
||||
if "EmptyArticle" in prompt:
|
||||
return FakeResp_news_service(json_data={"choices": [{"message": {"content": ""}}]})
|
||||
if "BadArticle" in prompt:
|
||||
return FakeResp_news_service(
|
||||
json_data={"choices": [{"message": {"content": "no number"}}]}
|
||||
)
|
||||
grade = "9" if "HighArticle" in prompt else "3"
|
||||
return FakeResp_news_service(json_data={"choices": [{"message": {"content": grade}}]})
|
||||
class FailingApiClient(FakeClient_news_service):
|
||||
async def get(self, url, timeout=None):
|
||||
if url == API_URL:
|
||||
raise httpx.HTTPError("api down")
|
||||
return FakeResp_news_service(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_internal_gateway_fallback(local_db, monkeypatch):
|
||||
from devplacepy.database import internal_gateway_key
|
||||
|
||||
monkeypatch.delenv("NEWS_AI_KEY", raising=False)
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
||||
assert _get_ai_key() == internal_gateway_key()
|
||||
|
||||
|
||||
def test_grade_article_empty_content_returns_none(local_db, monkeypatch):
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
||||
grade = run_async(
|
||||
NewsService()._grade_article(
|
||||
{"title": "EmptyArticle", "description": "d", "content": "c"},
|
||||
AI_URL,
|
||||
"m",
|
||||
FakeClient_news_service([]),
|
||||
)
|
||||
)
|
||||
assert grade is None
|
||||
|
||||
|
||||
def test_grade_article_unparseable_returns_none(local_db, monkeypatch):
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
||||
grade = run_async(
|
||||
NewsService()._grade_article(
|
||||
{"title": "BadArticle", "description": "d", "content": "c"},
|
||||
AI_URL,
|
||||
"m",
|
||||
FakeClient_news_service([]),
|
||||
)
|
||||
)
|
||||
assert grade is None
|
||||
|
||||
|
||||
def test_run_once_handles_api_failure(local_db, monkeypatch):
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
||||
monkeypatch.setattr(base_mod, "get_setting", _settings_stub())
|
||||
monkeypatch.setattr(
|
||||
news_mod.httpx, "AsyncClient", lambda *a, **k: FailingApiClient([])
|
||||
)
|
||||
run_async(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(base_mod, "get_setting", _settings_stub(threshold="7"))
|
||||
monkeypatch.setattr(
|
||||
news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient_news_service(articles)
|
||||
)
|
||||
|
||||
run_async(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_news_service([])
|
||||
|
||||
async def fake_get(url, timeout=None):
|
||||
return FakeResp_news_service(text=html)
|
||||
|
||||
client.get = fake_get
|
||||
images = run_async(_get_article_images("http://news.test/page", client))
|
||||
assert [img["url"] for img in images] == ["http://img.test/a.png"]
|
||||
|
||||
|
||||
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(base_mod, "get_setting", _settings_stub(threshold="7"))
|
||||
monkeypatch.setattr(
|
||||
news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient_news_service(articles)
|
||||
)
|
||||
|
||||
run_async(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"
|
||||
|
||||
run_async(NewsService().run_once())
|
||||
assert news.count(external_id=g_high) == 1
|
||||
assert news.count(external_id=g_fail) == 1
|
||||
Reference in New Issue
Block a user