"""news_sentiment 라운드3: UTC 기사 나이 정규화, 0건 비캐시, 연속 0건 경보.""" import asyncio from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock import pytest from app.core.config import settings from app.core.timeutil import now_kst from app.services import news_sentiment as ns @pytest.fixture(autouse=True) def _reset(monkeypatch): for st in ns._health.values(): st.update(consecutive_empty=0, last_success_at=None, alerted=False) # 네트워크/Redis/DB 는 호출 즉시 예외 (목 누락 시 거짓 통과 방지) def boom(*a, **k): raise AssertionError("unmocked external call") monkeypatch.setattr(ns, "get_json", AsyncMock(return_value=None)) monkeypatch.setattr(ns, "_save_news_to_db", boom) yield def test_utc_article_age_is_normalized_to_kst(): # 16시간 전 UTC 기사 -> 24h 이내 가중치 1.0 이어야 한다(구버그: 25h -> 0.6) pub = (datetime.now(timezone.utc) - timedelta(hours=16)).isoformat().replace("+00:00", "Z") recent = {"sentiment_score": -1.0, "published_at": pub} fresh = {"sentiment_score": 1.0, "published_at": now_kst()} # 둘 다 가중 1.0 이면 평균 0.0, 구버그면 (-0.6+1)/1.6=0.25 assert ns._compute_aggregate_score([recent, fresh]) == 0.0 def test_aware_datetime_object_normalized(): aware = datetime.now(timezone.utc) - timedelta(hours=16) out = ns._to_kst_naive(aware) assert out.tzinfo is None assert abs((now_kst() - out).total_seconds() - 16 * 3600) < 5 def test_empty_stock_news_not_cached_and_counted(monkeypatch): setex = AsyncMock() monkeypatch.setattr(ns, "setex_json", setex) monkeypatch.setattr(ns, "collect_stock_news", AsyncMock(return_value=[])) assert asyncio.run(ns.get_stock_news_score("005930")) == 0.0 keys = [c.args[0] for c in setex.call_args_list] assert "news_score:stock:005930" not in keys # 0.0 점수 캐시 금지 assert ns._health["stock"]["consecutive_empty"] == 1 def test_consecutive_empty_alerts_once(monkeypatch, caplog): from app.services import telegram_notifier as tn send = AsyncMock(return_value=True) monkeypatch.setattr(tn.telegram_notifier, "send_message", send) thr = int(getattr(settings, "NEWS_EMPTY_ALERT_THRESHOLD", ns._EMPTY_ALERT_DEFAULT)) async def run(): for _ in range(thr + 3): await ns._record_collect_result("market", 0) with caplog.at_level("ERROR"): asyncio.run(run()) assert send.await_count == 1 assert any(r.levelname == "ERROR" for r in caplog.records) def test_success_resets_and_records_time(): asyncio.run(ns._record_collect_result("stock", 0)) asyncio.run(ns._record_collect_result("stock", 3)) h = ns.get_news_collect_health()["stock"] assert h["consecutive_empty"] == 0 and h["last_success_at"] def test_stock_news_with_articles_is_cached(monkeypatch): setex = AsyncMock() monkeypatch.setattr(ns, "setex_json", setex) monkeypatch.setattr(ns, "_save_news_to_db", AsyncMock()) arts = [{"title": "어닝쇼크", "sentiment_score": -0.9, "published_at": now_kst()}] monkeypatch.setattr(ns, "collect_stock_news", AsyncMock(return_value=arts)) score = asyncio.run(ns.get_stock_news_score("005930")) assert score <= settings.NEWS_BLOCK_SCORE setex.assert_awaited_with("news_score:stock:005930", settings.NEWS_CACHE_TTL, score)