First deployment
This commit is contained in:
BIN
tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/__pycache__/test_db_workflows.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_db_workflows.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
69
tests/conftest.py
Normal file
69
tests/conftest.py
Normal 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
|
||||
19
tests/test_accessibility_contract.py
Normal file
19
tests/test_accessibility_contract.py
Normal 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
|
||||
43
tests/test_api_contracts.py
Normal file
43
tests/test_api_contracts.py
Normal 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"
|
||||
44
tests/test_db_workflows.py
Normal file
44
tests/test_db_workflows.py
Normal 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
|
||||
30
tests/test_e2e_critical_journeys.py
Normal file
30
tests/test_e2e_critical_journeys.py
Normal 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
|
||||
20
tests/test_e2e_edge_cases.py
Normal file
20
tests/test_e2e_edge_cases.py
Normal 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
|
||||
71
tests/test_p16_translation_and_refetch.py
Normal file
71
tests/test_p16_translation_and_refetch.py
Normal 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"
|
||||
16
tests/test_security_and_performance.py
Normal file
16
tests/test_security_and_performance.py
Normal 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
|
||||
23
tests/test_wcag_regression.py
Normal file
23
tests/test_wcag_regression.py
Normal 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
|
||||
Reference in New Issue
Block a user