import os import sqlite3 import pytest from fastapi.testclient import TestClient from app.screener.schema import ensure_screener_schema from app.db import init_db @pytest.fixture(autouse=True) def isolated_db_and_auth(tmp_path, monkeypatch): db_path = tmp_path / "stock.db" # 기본 stock DB 스키마 monkeypatch.setenv("STOCK_DB_PATH", str(db_path)) init_db() # screener 스키마 (news_sentiment, krx_master 등) c = sqlite3.connect(db_path) ensure_screener_schema(c) c.close() # WEBAI_API_KEY 활성화 monkeypatch.setenv("WEBAI_API_KEY", "test-secret") @pytest.fixture def client(): from app.main import app return TestClient(app) HEADERS_OK = {"X-WebAI-Key": "test-secret"} def _seed_portfolio(broker="키움", ticker="005930", name="삼성전자", quantity=100, avg_price=75000.0, purchase_price=75500.0): from app.db import add_portfolio_item return add_portfolio_item(broker, ticker, name, quantity, avg_price, purchase_price=purchase_price) def test_webai_portfolio_normal_response_includes_pnl_pct(client, monkeypatch): _seed_portfolio() # current_price 모킹 — profit_rate 4.67% 만들기 from app import main monkeypatch.setattr( main, "get_current_prices_detail", lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "2026-05-15T15:30:00"}} ) r = client.get("/api/webai/portfolio", headers=HEADERS_OK) assert r.status_code == 200 body = r.json() assert len(body["holdings"]) == 1 h = body["holdings"][0] assert h["pnl_pct"] is not None assert abs(h["pnl_pct"] - 0.0467) < 0.0005 # 0.0467 ± rounding def test_webai_portfolio_summary_has_total_pnl_pct(client, monkeypatch): _seed_portfolio() from app import main monkeypatch.setattr( main, "get_current_prices_detail", lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}} ) r = client.get("/api/webai/portfolio", headers=HEADERS_OK) body = r.json() assert "total_pnl_pct" in body["summary"] assert abs(body["summary"]["total_pnl_pct"] - 0.0467) < 0.0005 def test_webai_portfolio_pnl_pct_matches_profit_rate_divided_100(client, monkeypatch): _seed_portfolio() from app import main monkeypatch.setattr( main, "get_current_prices_detail", lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}} ) r = client.get("/api/webai/portfolio", headers=HEADERS_OK) h = r.json()["holdings"][0] assert h["pnl_pct"] == round(h["profit_rate"] / 100, 6) def test_webai_portfolio_missing_key_returns_401(client): r = client.get("/api/webai/portfolio") assert r.status_code == 401 assert "X-WebAI-Key" in r.json()["detail"] def _seed_news_sentiment(date_str: str, rows: list[tuple]): """rows: list of (ticker, score_raw, reason, news_count).""" db_path = os.environ["STOCK_DB_PATH"] c = sqlite3.connect(db_path) for ticker, score, reason, news_count in rows: c.execute( "INSERT OR REPLACE INTO news_sentiment " "(ticker, date, score_raw, reason, news_count, source) " "VALUES (?, ?, ?, ?, ?, 'articles')", (ticker, date_str, score, reason, news_count) ) c.commit() c.close() def _seed_krx_master(rows: list[tuple]): """rows: list of (ticker, name).""" db_path = os.environ["STOCK_DB_PATH"] c = sqlite3.connect(db_path) import datetime as dt now = dt.datetime.utcnow().isoformat() for ticker, name in rows: c.execute( "INSERT OR REPLACE INTO krx_master " "(ticker, name, market, market_cap, updated_at) VALUES (?, ?, 'KOSPI', 0, ?)", (ticker, name, now) ) c.commit() c.close() def test_webai_news_sentiment_returns_latest_date_when_no_param(client): _seed_krx_master([("005930", "삼성전자"), ("000660", "SK하이닉스")]) _seed_news_sentiment("2026-05-14", [("005930", 5.0, "old", 5)]) _seed_news_sentiment("2026-05-15", [ ("005930", 6.2, "HBM 양산 가시화", 12), ("000660", 5.5, "PPI 우려에도 강세", 8), ]) r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK) assert r.status_code == 200 body = r.json() assert body["date"] == "2026-05-15" assert body["count"] == 2 # sorted by score DESC assert body["items"][0]["ticker"] == "005930" assert body["items"][0]["score"] == 6.2 assert body["items"][0]["name"] == "삼성전자" assert body["items"][0]["reason"] == "HBM 양산 가시화" def test_webai_news_sentiment_filters_by_date_param(client): _seed_krx_master([("005930", "삼성전자")]) _seed_news_sentiment("2026-05-14", [("005930", 5.0, "yesterday", 5)]) _seed_news_sentiment("2026-05-15", [("005930", 6.2, "today", 12)]) r = client.get("/api/webai/news-sentiment?date=2026-05-14", headers=HEADERS_OK) body = r.json() assert body["date"] == "2026-05-14" assert body["count"] == 1 assert body["items"][0]["reason"] == "yesterday" def test_webai_news_sentiment_empty_table_returns_count_zero(client): r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK) body = r.json() assert body["date"] is None assert body["count"] == 0 assert body["items"] == [] def test_webai_news_sentiment_items_sorted_by_score_desc(client): _seed_krx_master([("A", "A주"), ("B", "B주"), ("C", "C주")]) _seed_news_sentiment("2026-05-15", [ ("A", 1.0, "low", 1), ("B", 9.0, "high", 1), ("C", 5.0, "mid", 1), ]) r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK) items = r.json()["items"] assert [i["score"] for i in items] == [9.0, 5.0, 1.0] def test_webai_401_response_has_no_payload_leak(client): """인증 실패 응답에는 portfolio/sentiment 데이터가 없어야 한다.""" _seed_portfolio() r = client.get("/api/webai/portfolio") # 헤더 없음 assert r.status_code == 401 body = r.json() assert "holdings" not in body assert "cash" not in body assert "summary" not in body def test_webai_503_when_env_missing(client, monkeypatch): """WEBAI_API_KEY env 미설정 시 503, 다른 endpoint 영향 없음.""" monkeypatch.delenv("WEBAI_API_KEY", raising=False) r1 = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "anything"}) assert r1.status_code == 503 # 기존 endpoint 무영향 — /api/portfolio 는 200 (빈 portfolio) r2 = client.get("/api/portfolio") assert r2.status_code == 200 def test_webai_wrong_key_returns_401(client): r = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "wrong"}) assert r.status_code == 401 def test_webai_news_sentiment_unknown_date_returns_empty(client): r = client.get("/api/webai/news-sentiment?date=1999-01-01", headers=HEADERS_OK) assert r.status_code == 200 body = r.json() assert body["count"] == 0 assert body["items"] == []