venthrive-api / tests /test_news.py
GitHub Actions
Sync from GitHub · 2026-10-20 19:36 UTC
a32af27
Raw History Blame Contribute Delete
6.73 kB
"""
Unit tests for the business news system (ADR-0015): dedup hashing, relevance
windows, business-type validation, and classification completeness logic.
classify_item goes through real Groq in production; here it's exercised only
in E2E_FIXTURE_MODE against canned fixtures, same pattern as
test_groq_fixture_mode.py — no network calls.
"""
import importlib
from datetime import date, datetime, timezone
import pytest
from news.dedup import url_hash
from news.windows import compute_expiry, CATEGORY_WINDOW_DAYS, DEFAULT_WINDOW_DAYS
from news.business_types import valid_subset, VALID_IDS
from news.classify import ClassificationResult
from news.pipeline import _route
# ─── dedup ──────────────────────────────────────────────────────────────────
def test_url_hash_ignores_trailing_slash():
assert url_hash("https://example.com/a") == url_hash("https://example.com/a/")
def test_url_hash_ignores_query_string():
assert url_hash("https://example.com/a") == url_hash("https://example.com/a?utm_source=x")
def test_url_hash_ignores_fragment():
assert url_hash("https://example.com/a") == url_hash("https://example.com/a#section")
def test_url_hash_distinguishes_different_urls():
assert url_hash("https://example.com/a") != url_hash("https://example.com/b")
# ─── windows ────────────────────────────────────────────────────────────────
def test_compute_expiry_uses_category_window():
scraped_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
expiry = compute_expiry("weather", scraped_at, None)
assert expiry == scraped_at + __import__("datetime").timedelta(days=CATEGORY_WINDOW_DAYS["weather"])
def test_compute_expiry_falls_back_to_default_for_unknown_category():
scraped_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
expiry = compute_expiry(None, scraped_at, None)
assert expiry == scraped_at + __import__("datetime").timedelta(days=DEFAULT_WINDOW_DAYS)
def test_compute_expiry_deadline_overrides_category_window():
scraped_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
deadline = date(2026, 12, 1)
expiry = compute_expiry("funding", scraped_at, deadline)
assert expiry == datetime(2026, 12, 1, tzinfo=timezone.utc)
# ─── business_types ───────────────────────────────────────────────────────
def test_valid_subset_drops_hallucinated_ids():
real_id = next(iter(VALID_IDS))
assert valid_subset([real_id, "not-a-real-subsector"]) == [real_id]
def test_valid_subset_empty_input():
assert valid_subset([]) == []
# ─── classify (fixture mode) ───────────────────────────────────────────────
@pytest.fixture
def classify_module(monkeypatch):
monkeypatch.setenv("E2E_FIXTURE_MODE", "1")
import utils.groq_client as gc
importlib.reload(gc)
import news.classify as classify
importlib.reload(classify)
yield classify
monkeypatch.delenv("E2E_FIXTURE_MODE", raising=False)
importlib.reload(gc)
importlib.reload(classify)
def test_classify_item_complete_result_is_complete(classify_module):
result = classify_module.classify_item("Lagos flood warning", "text", fixture="news_classify_complete")
assert result.is_complete
assert result.review_reason() is None
assert result.impact == "neutral"
assert result.category == "weather"
def test_classify_item_incomplete_result_flags_missing_fields(classify_module):
result = classify_module.classify_item("Unclear item", "text", fixture="news_classify_incomplete")
assert not result.is_complete
reason = result.review_reason()
assert "category" in reason
assert "business_types" in reason
assert "location" in reason
def test_classify_item_opportunity_keeps_deadline(classify_module):
result = classify_module.classify_item("State SME grant", "text", fixture="news_classify_opportunity")
assert result.impact == "opportunity"
assert result.deadline == date(2026, 12, 1)
def test_classify_item_deadline_dropped_when_not_opportunity(classify_module):
result = classify_module.classify_item("Lagos flood warning", "text", fixture="news_classify_complete")
assert result.deadline is None
def test_classification_result_drops_invalid_enum_values():
result = __import__("news.classify", fromlist=["ClassificationResult"]).ClassificationResult({
"impact": "not-a-real-impact",
"category": "not-a-real-category",
"business_types": [],
"location": "Lagos",
})
assert result.impact is None
assert result.category is None
def test_classification_result_malformed_deadline_is_ignored():
from news.classify import ClassificationResult
result = ClassificationResult({
"impact": "opportunity",
"category": "funding",
"business_types": [],
"location": "Lagos",
"deadline": "not-a-date",
})
assert result.deadline is None
# ─── confidence routing ─────────────────────────────────────────────────────
_SOME_VALID_ID = next(iter(VALID_IDS))
def _result(impact="neutral", category="weather", business_types=None, location="Lagos", confidence=None):
business_types = [_SOME_VALID_ID] if business_types is None else business_types
return ClassificationResult({
"impact": impact, "category": category,
"business_types": business_types, "location": location,
"confidence": confidence,
})
def test_route_auto_publishes_complete_high_confidence():
assert _route(_result(confidence=0.9)) == "classified"
def test_route_sends_complete_low_confidence_to_review():
assert _route(_result(confidence=0.6)) == "needs_review"
def test_route_auto_discards_incomplete_low_confidence():
# e.g. celebrity gossip the model correctly isn't confident about
assert _route(_result(category=None, business_types=(), location=None, confidence=0.2)) == "rejected"
def test_route_sends_incomplete_but_confident_to_review():
assert _route(_result(location=None, confidence=0.7)) == "needs_review"
def test_route_treats_missing_confidence_as_zero():
assert _route(_result(category=None, business_types=(), location=None, confidence=None)) == "rejected"