First deployment
Some checks failed
quality-gates / lint-and-test (push) Has been cancelled
quality-gates / security-scan (push) Has been cancelled

This commit is contained in:
2026-02-13 09:14:04 -05:00
parent 0e21e035f5
commit 679561bcdb
128 changed files with 3479 additions and 120 deletions

69
tests/conftest.py Normal file
View File

@@ -0,0 +1,69 @@
import datetime
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from sqlalchemy.orm import Session, sessionmaker
from backend.database import Base
from backend.main import app, get_db
from backend.repository import create_news, create_translation
@pytest.fixture
def db_session() -> Iterator[Session]:
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
testing_session_local = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Base.metadata.create_all(bind=engine)
session = testing_session_local()
try:
yield session
finally:
session.close()
@pytest.fixture
def client(db_session: Session) -> Iterator[TestClient]:
def override_get_db() -> Iterator[Session]:
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def seeded_news(db_session: Session) -> int:
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
item = create_news(
db=db_session,
headline="Market rally follows AI earnings beat",
summary="Major indices rose after strong AI-focused earnings results.",
source_url="https://example.com/market-rally",
image_url="/static/images/placeholder.png",
image_credit="Example",
tldr_points=["Markets rose", "AI earnings strong", "Investors optimistic"],
summary_body="A broad market rally followed stronger-than-expected AI earnings.",
source_citation="Example source",
summary_image_url="/static/images/placeholder.png",
summary_image_credit="Example",
published_at=now,
)
create_translation(
db=db_session,
news_item_id=item.id,
language="ta",
headline="ஏஐ வருமானத்துக்கு பின் சந்தை உயர்வு",
summary="ஏஐ சார்ந்த வருமானம் உயர்வைத் தொடர்ந்து சந்தை முன்னேறியது.",
)
return item.id

View File

@@ -0,0 +1,19 @@
def test_homepage_has_skip_link_and_landmarks(client):
response = client.get("/")
assert response.status_code == 200
html = response.text
assert 'href="#main-content"' in html
assert '<main id="main-content">' in html
def test_modal_and_share_controls_have_accessible_labels(client):
response = client.get("/")
assert response.status_code == 200
html = response.text
assert 'aria-label="Close summary modal"' in html
assert 'aria-label="Share on X"' in html
assert 'aria-label="Share on WhatsApp"' in html
assert 'aria-label="Share on LinkedIn"' in html
assert 'aria-label="Copy article link"' in html
assert 'aria-label="Back to top"' in html
assert 'aria-label="Close policy modal"' in html

View File

@@ -0,0 +1,43 @@
def test_health_endpoint_contract(client):
response = client.get("/api/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "version" in data
assert isinstance(data["news_count"], int)
def test_config_endpoint_exposes_footer_keys(client):
response = client.get("/config")
assert response.status_code == 200
data = response.json()
assert "github_repo_url" in data
assert "contact_email" in data
def test_latest_news_contract(client, seeded_news):
response = client.get("/api/news/latest?language=en")
assert response.status_code == 200
payload = response.json()
assert payload["id"] == seeded_news
assert payload["headline"]
assert payload["summary"]
assert payload["language"] == "en"
def test_news_pagination_and_limit_validation(client, seeded_news):
response = client.get("/api/news?limit=10&language=en")
assert response.status_code == 200
payload = response.json()
assert isinstance(payload["items"], list)
assert payload["items"][0]["id"] == seeded_news
invalid_limit = client.get("/api/news?limit=999")
assert invalid_limit.status_code == 422
def test_language_fallback_defaults_to_english(client, seeded_news):
response = client.get("/api/news/latest?language=zz")
assert response.status_code == 200
payload = response.json()
assert payload["language"] == "en"

View File

@@ -0,0 +1,44 @@
import datetime
from backend.repository import (
archive_old_news,
create_news,
create_translation,
delete_archived_news,
get_translation,
)
def test_translation_persistence(db_session):
item = create_news(
db=db_session,
headline="AI chip demand surges",
summary="Demand for AI chips increased this quarter.",
)
create_translation(
db=db_session,
news_item_id=item.id,
language="ml",
headline="എഐ ചിപ്പ് ആവശ്യകത ഉയർന്നു",
summary="ഈ പാദത്തിൽ എഐ ചിപ്പ് ആവശ്യകത ഉയർന്നു.",
)
translation = get_translation(db_session, item.id, "ml")
assert translation is not None
assert translation.language == "ml"
def test_archiving_and_deletion_workflow(db_session):
old_item = create_news(
db=db_session,
headline="Old item",
summary="Old item summary",
published_at=datetime.datetime.utcnow() - datetime.timedelta(days=120),
)
old_item.created_at = datetime.datetime.utcnow() - datetime.timedelta(days=120)
db_session.commit()
archived_count = archive_old_news(db_session, retention_days=30)
assert archived_count >= 1
deleted_count = delete_archived_news(db_session, days_after_archive=60)
assert deleted_count >= 1

View File

@@ -0,0 +1,30 @@
def test_e2e_homepage_renders_hero_feed_and_modal_shell(client, seeded_news):
response = client.get("/")
assert response.status_code == 200
html = response.text
assert 'x-data="heroBlock()"' in html
assert 'x-data="newsFeed()"' in html
assert 'aria-label="Article summary"' in html
def test_e2e_api_hero_and_feed_journey(client, seeded_news):
latest = client.get("/api/news/latest?language=en")
assert latest.status_code == 200
latest_payload = latest.json()
assert latest_payload["id"] == seeded_news
feed = client.get("/api/news?limit=10&language=en")
assert feed.status_code == 200
items = feed.json()["items"]
assert len(items) >= 1
assert items[0]["id"] == seeded_news
def test_e2e_permalink_and_share_hooks_present(client):
response = client.get("/")
html = response.text
assert "function articlePermalink(item)" in html
assert "function getPermalinkArticleId()" in html
assert "shareLink('x', modalItem)" in html
assert "shareLink('whatsapp', modalItem)" in html
assert "shareLink('linkedin', modalItem)" in html

View File

@@ -0,0 +1,20 @@
def test_e2e_invalid_language_falls_back_without_error(client, seeded_news):
response = client.get("/api/news/latest?language=xx")
assert response.status_code == 200
payload = response.json()
assert payload["language"] == "en"
def test_e2e_invalid_permalink_query_does_not_break_home(client):
response = client.get("/?article=not-a-number")
assert response.status_code == 200
html = response.text
assert 'x-data="newsFeed()"' in html
def test_e2e_empty_feed_shape_is_stable(client):
response = client.get("/api/news?limit=10&language=en")
assert response.status_code == 200
payload = response.json()
assert "items" in payload
assert "has_more" in payload

View File

@@ -0,0 +1,71 @@
import pytest
from backend.cli import is_unrelated_image_candidate, resolve_article_id_from_permalink
from backend.news_service import (
GENERIC_FINANCE_FALLBACK_URL,
fetch_royalty_free_image,
validate_translation_quality,
)
def test_permalink_resolution_supports_numeric_and_query_formats():
assert resolve_article_id_from_permalink("123") == 123
assert resolve_article_id_from_permalink("/?article=456") == 456
assert resolve_article_id_from_permalink("https://x.test/?article=789") == 789
assert resolve_article_id_from_permalink("bad") is None
def test_unrelated_image_candidate_filters_pet_terms():
assert is_unrelated_image_candidate("https://img.example.com/cat-photo.jpg", "Example")
assert is_unrelated_image_candidate("https://img.example.com/market.jpg", "Wildlife digest")
assert not is_unrelated_image_candidate(
"https://img.example.com/nasdaq-chart.jpg", "Market Desk"
)
def test_translation_quality_validation_accepts_valid_tamil_and_malayalam():
ta_ok, ta_reason = validate_translation_quality(
"ஏஐ முதலீட்டில் சந்தை ஏற்றம்",
"ஏஐ சார்ந்த முதலீட்டு செய்திகளால் இந்திய சந்தையில் உயர்வு ஏற்பட்டது.",
"ta",
)
ml_ok, ml_reason = validate_translation_quality(
"എഐ നിക്ഷേപ വാർത്തകൾ വിപണിയെ ഉയർത്തി",
"എഐ കേന്ദ്രിത സാമ്പത്തിക റിപ്പോർട്ടുകൾ മൂലം മാർക്കറ്റ് മുന്നേറ്റം രേഖപ്പെടുത്തി.",
"ml",
)
assert ta_ok and ta_reason is None
assert ml_ok and ml_reason is None
def test_translation_quality_validation_rejects_script_mismatch_and_gibberish():
mismatch_ok, mismatch_reason = validate_translation_quality(
"AI market rally",
"Stocks up after AI earnings beat expectations.",
"ta",
)
gibberish_ok, gibberish_reason = validate_translation_quality(
"aaaaaaaabbbbbbbb",
"xxxxxxxxyyyyyyyy",
"ml",
)
assert not mismatch_ok
assert mismatch_reason == "script-mismatch"
assert not gibberish_ok
assert gibberish_reason in {"repeated-sequence", "script-mismatch"}
@pytest.mark.anyio
async def test_finance_refetch_guard_rejects_pet_image_and_uses_finance_fallback(monkeypatch):
async def fake_provider(_query: str):
return "https://example.com/cat-on-wall-street.jpg", "cat photo"
monkeypatch.setattr(
"backend.news_service.get_enabled_providers",
lambda: [("fake", fake_provider)],
)
monkeypatch.setattr("backend.config.ROYALTY_IMAGE_MCP_ENDPOINT", "")
image_url, credit = await fetch_royalty_free_image("stock market rally and earnings")
assert image_url == GENERIC_FINANCE_FALLBACK_URL
assert credit == "Finance-safe fallback"

View File

@@ -0,0 +1,16 @@
import time
def test_security_headers_present(client):
response = client.get("/")
assert response.status_code == 200
assert response.headers.get("x-content-type-options") == "nosniff"
assert "cache-control" in response.headers
def test_api_runtime_budget_smoke(client, seeded_news):
start = time.monotonic()
response = client.get("/api/news?limit=10&language=en")
elapsed = time.monotonic() - start
assert response.status_code == 200
assert elapsed < 0.8

View File

@@ -0,0 +1,23 @@
def test_keyboard_focus_and_escape_hooks_exist(client):
response = client.get("/")
assert response.status_code == 200
html = response.text
assert "*:focus-visible" in html
assert '@keydown.escape.window="closeSummary()"' in html
assert "function policyDisclosures()" in html
def test_icon_controls_and_labels_exist(client):
response = client.get("/")
html = response.text
assert 'aria-label="Copy article link"' in html
assert 'aria-label="Back to top"' in html
assert "function backToTopIsland()" in html
def test_policy_modal_accessibility_contract(client):
response = client.get("/")
html = response.text
assert 'role="dialog"' in html
assert 'aria-modal="true"' in html
assert 'aria-label="Close policy modal"' in html