Some checks failed
build-and-deploy / build (push) Failing after 4s
Vendor the newsPipeline scraper + summarizer into the repo and wire them into
docker-compose against the EXISTING osint-db (no second Postgres), replacing
the upstream k8s CronJobs with in-compose wall-clock loops (:00 scrape, :05
summarize).
- news/scraper: vendored Scrapy project (257 RSS feeds) + hourly loop
scheduler (run_news_scraper.py)
- news/summerizer: vendored Gemini map-reduce summarizer, cleaned:
* fix broken google-genai response handling (_extract_text, defensive)
* fix malformed INSERT/GRANT query in save_summary_to_db
* OSINT-neutral default MAP_PROMPT; futures/markets language gated behind
INCLUDE_FUTURES=0 (yfinance lazy-imported)
* env-configurable model, batch size, lookback window
+ hourly loop scheduler (run_news_summarizer.py, :05)
- alembic 003_news: idempotent articles + article_summaries tables
- API: GET /api/news and GET /api/news/summaries (+ models, schemas)
- tests/test_api_news.py: 5 DB-backed contract tests (all pass vs real PG)
- docs/news.md + .env.example updates
Both services run under the `ingest` compose profile (matching the
ingester/camera-scraper pattern) and build arm64 on the Pi via the existing
Forgejo CI workflow. telebot left out of scope (reserved env only).
142 lines
4.2 KiB
Python
142 lines
4.2 KiB
Python
"""Integration tests for the news pipeline API (GET /api/news + summaries).
|
|
|
|
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` 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")
|
|
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) -> None:
|
|
async def run():
|
|
conn = await asyncpg.connect(**_conn_kwargs())
|
|
try:
|
|
await conn.execute(
|
|
"INSERT INTO article_summaries (summary_text, batch_timestamp) "
|
|
"VALUES ($1, $2)",
|
|
text, datetime.fromisoformat(ts),
|
|
)
|
|
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"}
|
|
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() == []
|