"""Integration tests for the news pipeline API (GET /api/news + summaries + intel). DB-backed: marked `requires_db` and auto-skip when the test database is unreachable (see tests/conftest.py). Seeding writes directly to the shared `articles` / `article_summaries` / `news_items` tables, exactly as the scraper + summarizer services would. """ from __future__ import annotations import asyncio import os from datetime import datetime, timezone import asyncpg import httpx import pytest from conftest import requires_db from main import app BASE = "http://test" def _conn_kwargs() -> dict: return { "host": os.environ["DB_HOST"], "port": int(os.environ["DB_PORT"]), "user": os.environ["DB_USER"], "password": os.environ["DB_PASSWORD"], "database": os.environ["DB_NAME"], } def _truncate() -> None: async def run(): conn = await asyncpg.connect(**_conn_kwargs()) try: await conn.execute( "TRUNCATE articles, article_summaries, news_items CASCADE" ) finally: await conn.close() asyncio.run(run()) @pytest.fixture() def clean_news(): _truncate() yield _truncate() def _seed_article(title: str, url: str, domain: str, ts: str, content: str = "body text") -> None: async def run(): conn = await asyncpg.connect(**_conn_kwargs()) try: await conn.execute( "INSERT INTO articles (title, url, content, domain, timestamp) " "VALUES ($1, $2, $3, $4, $5)", title, url, content, domain, datetime.fromisoformat(ts), ) finally: await conn.close() asyncio.run(run()) def _seed_summary(text: str, ts: str, model: str | None = None) -> int: async def run() -> int: conn = await asyncpg.connect(**_conn_kwargs()) try: row = await conn.fetchrow( "INSERT INTO article_summaries (summary_text, batch_timestamp, model) " "VALUES ($1, $2, $3) RETURNING id", text, datetime.fromisoformat(ts), model, ) return int(row["id"]) finally: await conn.close() return asyncio.run(run()) def _seed_news_item( summary_id: int, kind: str, headline: str, importance: str, *, location_name: str | None = None, lat: float | None = None, lon: float | None = None, location_confidence: str | None = None, category: str | None = None, url: str | None = None, ) -> None: async def run(): conn = await asyncpg.connect(**_conn_kwargs()) try: await conn.execute( "INSERT INTO news_items " "(summary_id, kind, headline, importance, location_name, " " lat, lon, location_confidence, category, url) " "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", summary_id, kind, headline, importance, location_name, lat, lon, location_confidence, category, url, ) finally: await conn.close() asyncio.run(run()) def _get(path: str) -> httpx.Response: async def _get_async() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url=BASE) as client: return await client.get(path) return asyncio.run(_get_async()) @requires_db def test_api_news_returns_newest_first(clean_news): _seed_article("older", "https://a.example/1", "a.example", "2026-08-24T17:00:00+00:00") _seed_article("newer", "https://a.example/2", "a.example", "2026-08-24T18:00:00+00:00") body = _get("/api/news").json() assert isinstance(body, list) assert len(body) == 2 # newest first assert [a["title"] for a in body] == ["newer", "older"] # exact JSON contract the frontend news panel needs assert set(body[0].keys()) == { "id", "title", "url", "content", "domain", "timestamp", } assert body[0]["domain"] == "a.example" @requires_db def test_api_news_domain_filter(clean_news): _seed_article("x", "https://x.example/1", "x.example", "2026-08-24T17:00:00+00:00") _seed_article("y", "https://y.example/1", "y.example", "2026-08-24T17:00:00+00:00") body = _get("/api/news?domain=y.example").json() assert len(body) == 1 assert body[0]["domain"] == "y.example" @requires_db def test_api_news_since_filter(clean_news): _seed_article("before", "https://a.example/1", "a.example", "2026-08-24T17:00:00+00:00") _seed_article("after", "https://a.example/2", "a.example", "2026-08-24T18:30:00+00:00") body = _get("/api/news?since=2026-08-24T18:00:00Z").json() assert len(body) == 1 assert body[0]["title"] == "after" @requires_db def test_api_news_summaries_contract(clean_news): _seed_summary("master summary markdown…", "2026-08-24T18:05:00+00:00") body = _get("/api/news/summaries").json() assert isinstance(body, list) assert len(body) == 1 s = body[0] assert set(s.keys()) == {"id", "summary_text", "batch_timestamp", "model"} assert s["summary_text"] == "master summary markdown…" assert s["batch_timestamp"].startswith("2026-08-24T18:05") @requires_db def test_api_news_empty(clean_news): assert _get("/api/news").json() == [] assert _get("/api/news/summaries").json() == [] assert _get("/api/news/ticker").json() == [] assert _get("/api/news/map").json() == [] TICKER_KEYS = {"id", "headline", "importance", "location_name", "url", "created_at"} MAP_KEYS = { "id", "headline", "importance", "location_name", "lat", "lon", "location_confidence", "category", "url", "created_at", } def _seed_flagged_items() -> None: sid = _seed_summary("batch brief", "2026-08-27T18:05:00+00:00", "Hermes-4.3-36B") _seed_news_item( sid, "ticker", "Critical ticker", "critical", location_name="Kyiv", url="https://example.com/ticker", ) _seed_news_item( sid, "ticker", "Low ticker", "low", location_name="Somewhere", url="https://example.com/low", ) _seed_news_item( sid, "map", "Critical map", "critical", location_name="Taipei", lat=25.03, lon=121.56, location_confidence="high", category="conflict", url="https://example.com/map", ) @requires_db def test_api_news_ticker_returns_only_flagged(clean_news): _seed_flagged_items() resp = _get("/api/news/ticker") assert resp.status_code == 200 body = resp.json() assert isinstance(body, list) assert len(body) == 1 item = body[0] assert set(item.keys()) == TICKER_KEYS assert item["headline"] == "Critical ticker" assert item["importance"] == "critical" assert item["location_name"] == "Kyiv" assert item["url"] == "https://example.com/ticker" @requires_db def test_api_news_map_returns_only_flagged_with_coords(clean_news): _seed_flagged_items() resp = _get("/api/news/map") assert resp.status_code == 200 body = resp.json() assert isinstance(body, list) assert len(body) == 1 item = body[0] assert set(item.keys()) == MAP_KEYS assert item["headline"] == "Critical map" assert item["importance"] == "critical" assert item["lat"] == 25.03 assert item["lon"] == 121.56 assert item["location_confidence"] == "high" assert item["category"] == "conflict" assert _get("/api/news/map?bbox=1,2,3").status_code == 422