""" Unit tests for vendor → row dict transforms in headline_ingest_service. These exercise the data-shape contract without hitting the database. """ from datetime import datetime, timezone from app.services.news.alpaca_news_client import AlpacaNewsArticle from app.services.news.headline_ingest_service import ( alpaca_articles_to_rows, finnhub_articles_to_rows, stocktwits_messages_to_rows, ) def _alpaca_article(symbols, headline="Test", source="benzinga"): return AlpacaNewsArticle( id=12345, headline=headline, summary="summary", url="https://example.com/n/12345", author="Author", created_at=datetime(2026, 4, 25, 13, 30, tzinfo=timezone.utc), updated_at=datetime(2026, 4, 25, 13, 30, tzinfo=timezone.utc), source=source, symbols=symbols, images=[], content=None, ) def test_alpaca_emits_one_row_per_ticker_with_primary_flag(): art = _alpaca_article(["AAPL", "MSFT", "NVDA"], headline="Tech rally continues") rows = alpaca_articles_to_rows([art]) assert len(rows) == 3 tickers = [r["ticker"] for r in rows] assert tickers == ["AAPL", "MSFT", "NVDA"] primary_flags = [r["is_primary"] for r in rows] assert primary_flags == [True, False, False] # tickers_all preserves the full set on every row for r in rows: assert r["tickers_all"] == ["AAPL", "MSFT", "NVDA"] assert r["source"] == "alpaca_benzinga" assert r["source_id"] == "12345" def test_alpaca_skips_articles_with_no_symbols(): art = _alpaca_article([]) assert alpaca_articles_to_rows([art]) == [] def test_alpaca_published_at_is_tz_aware_utc(): rows = alpaca_articles_to_rows([_alpaca_article(["AAPL"])]) assert rows[0]["published_at"].tzinfo is not None assert rows[0]["ingested_at"].tzinfo is not None def test_alpaca_normalizes_categories_with_headline_override(): art = _alpaca_article(["AAPL"], headline="Goldman downgrades AAPL to Sell") rows = alpaca_articles_to_rows([art]) assert "analyst_rating_downgrade" in rows[0]["categories"] def test_finnhub_payload_to_rows(): payload = [{ "id": 999, "datetime": 1745596800, # 2025-04-25 16:00 UTC "headline": "Apple posts strong earnings", "summary": "Beat on EPS and revenue", "url": "https://finn/news/999", "category": "earnings", }] rows = finnhub_articles_to_rows("AAPL", payload) assert len(rows) == 1 r = rows[0] assert r["source"] == "finnhub" assert r["source_id"] == "999" assert r["ticker"] == "AAPL" assert r["is_primary"] is True assert "earnings_release" in r["categories"] assert r["published_at"].tzinfo is not None def test_finnhub_skips_rows_missing_id_or_timestamp(): payload = [ {"id": 1, "datetime": None, "headline": "x"}, # missing ts {"datetime": 100, "headline": "x"}, # missing id ] assert finnhub_articles_to_rows("AAPL", payload) == [] def test_stocktwits_bullish_score_and_dedup_key(): msg = { "id": 555, "created_at": "2026-04-25T13:00:00Z", "body": "AAPL to the moon", "entities": {"sentiment": {"basic": "Bullish"}}, "symbols": [{"symbol": "AAPL"}, {"symbol": "MSFT"}], } rows = stocktwits_messages_to_rows("AAPL", [msg]) assert len(rows) == 1 r = rows[0] assert r["source"] == "stocktwits" assert r["source_id"] == "555" assert r["raw_sentiment"] == 1.0 assert "AAPL" in r["tickers_all"] and "MSFT" in r["tickers_all"] assert r["published_at"].tzinfo is not None def test_stocktwits_bearish_score_negative(): msg = { "id": 1, "created_at": "2026-04-25T13:00:00Z", "body": "shorting this dog", "entities": {"sentiment": {"basic": "Bearish"}}, } rows = stocktwits_messages_to_rows("AAPL", [msg]) assert rows[0]["raw_sentiment"] == -1.0 def test_stocktwits_neutral_returns_none_sentiment(): msg = { "id": 2, "created_at": "2026-04-25T13:00:00Z", "body": "watching this stock today", "entities": {}, } rows = stocktwits_messages_to_rows("AAPL", [msg]) assert rows[0]["raw_sentiment"] is None def test_stocktwits_skips_empty_body(): msg = {"id": 3, "created_at": "2026-04-25T13:00:00Z", "body": " "} assert stocktwits_messages_to_rows("AAPL", [msg]) == []