osint-dashboard/tests/test_api_news.py

265 lines
8.4 KiB
Python
Raw Permalink Normal View History

"""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, kind: 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, kind) "
"VALUES ($1, $2, $3, $4) RETURNING id",
text, datetime.fromisoformat(ts), model, kind,
)
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", "kind"}
assert s["summary_text"] == "master summary markdown…"
assert s["batch_timestamp"].startswith("2026-08-24T18:05")
assert s["kind"] is None
@requires_db
def test_api_news_summaries_kind_filter(clean_news):
_seed_summary("interval brief", "2026-08-28T22:05:00+00:00", kind="interval")
_seed_summary("daily recap", "2026-08-28T03:00:00+00:00", kind="daily_recap")
recap = _get("/api/news/summaries?kind=daily_recap").json()
assert len(recap) == 1
assert recap[0]["summary_text"] == "daily recap"
assert recap[0]["kind"] == "daily_recap"
assert _get("/api/news/summaries?kind=nope").status_code == 422
@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_ticker_falls_back_to_lesser_when_nothing_flagged(clean_news):
sid = _seed_summary("quiet brief", "2026-08-27T18:05:00+00:00", "Hermes-4.3-36B")
_seed_news_item(
sid, "ticker", "Shop theft downtown", "low",
location_name="Raleigh", url="https://example.com/theft",
)
resp = _get("/api/news/ticker")
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
assert body[0]["headline"] == "Shop theft downtown"
assert body[0]["importance"] == "low"
@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