stock-analysis-api / tests /test_main.py
vjeai's picture
Deploy: all fixes β€” yfinance candles, ml_signal 2y history, no handoff schemas, sequential report phase
3be03dd
Raw
History Blame Contribute Delete
9.09 kB
"""
Tests for backend/main.py β€” FastAPI endpoints.
Covers:
- Ticker input validation (valid + invalid)
- DEMO_MODE staging response
- Rate limiting logic
- Quick signal caching
- HTTP method restrictions
"""
import json
import time
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from fastapi.testclient import TestClient
# ── Fixture helpers ────────────────────────────────────────────────────────────
def _make_client(demo_mode: bool = False):
"""Import app fresh with the desired DEMO_MODE."""
import importlib
import sys
# Patch env before importing app
env_patch = {
"DEMO_MODE": "true" if demo_mode else "false",
"LLM_MODE": "cloud",
}
with patch.dict("os.environ", env_patch):
# Force re-import so DEMO_MODE constant is re-evaluated
for mod in list(sys.modules.keys()):
if "backend.main" in mod or mod == "backend.main":
del sys.modules[mod]
import backend.main as app_module # noqa: PLC0415
importlib.reload(app_module)
return TestClient(app_module.app), app_module
# ── Ticker validation ─────────────────────────────────────────────────────────
class TestTickerValidation:
def setup_method(self):
self.client, self.mod = _make_client()
@pytest.mark.parametrize("ticker", [
"AAPL", "NVDA", "BRK.B", "SPY", "QQQ", "BP", "HSBA",
])
def test_valid_tickers_accepted_quick(self, ticker):
with patch.object(self.mod, "fuse_signals", return_value={"recommendation": "BUY"}):
r = self.client.get(f"/quick/{ticker}")
assert r.status_code != 400, f"Valid ticker {ticker} was rejected"
@pytest.mark.parametrize("bad", [
"../etc", # path traversal attempt
"A" * 11, # too long
"DROP;TABLE", # injection attempt
"<script>", # XSS attempt
"", # empty (handled by route matching)
])
def test_invalid_tickers_rejected_quick(self, bad):
r = self.client.get(f"/quick/{bad}")
# Path-traversal attempts ('../etc') are normalised by Starlette β†’ 404;
# other invalid inputs reach our validator and return 400.
# Either way the request must NOT succeed (not 200).
assert r.status_code in (400, 404)
@pytest.mark.parametrize("bad", [
"../etc",
"A" * 11,
"'; DROP TABLE--",
"<img src=x>",
])
def test_invalid_tickers_rejected_stream(self, bad):
r = self.client.get(f"/stream/{bad}")
assert r.status_code in (400, 404)
# ── Demo mode ─────────────────────────────────────────────────────────────────
class TestDemoMode:
def setup_method(self):
self.client, self.mod = _make_client(demo_mode=True)
def test_stream_returns_demo_event(self):
r = self.client.get("/stream/AAPL")
assert r.status_code == 200
lines = [l for l in r.text.splitlines() if l.startswith("data:")]
events = [json.loads(l[6:]) for l in lines if l[6:].strip() != "[DONE]"]
assert len(events) == 1
assert events[0].get("demo") is True
assert "Private Beta" in events[0]["content"]
def test_stream_demo_does_not_call_llm(self):
# In DEMO_MODE the endpoint returns a staged response before ever
# reaching the orchestrator β€” verify no LLM-related error key is present.
r = self.client.get("/stream/AAPL")
assert r.status_code == 200
body = r.text
assert "error" not in body.lower() or "Private Beta" in body
# ── Rate limiting ─────────────────────────────────────────────────────────────
class TestRateLimiting:
def setup_method(self):
self.client, self.mod = _make_client(demo_mode=False)
self.mod._IP_LAST_RUN.clear()
self.mod._STREAM_CACHE.clear()
def test_first_request_allowed(self):
# Seed the cache so the endpoint serves the cached path
# (orchestrator is not imported in main.py yet β€” cache path is safe)
self.mod._STREAM_CACHE["AAPL"] = (
time.time(),
[{"agent": "test", "content": "cached"}],
)
r = self.client.get("/stream/AAPL")
assert r.status_code != 429
def test_second_request_within_window_rate_limited(self):
# Simulate a recent run from this IP
ip = "testclient"
self.mod._IP_LAST_RUN[ip] = time.time()
r = self.client.get("/stream/MSFT")
assert r.status_code == 429
body = r.json()
assert "retry_after" in body
def test_cache_hit_bypasses_rate_limit(self):
ip = "testclient"
# Rate-limited IP
self.mod._IP_LAST_RUN[ip] = time.time()
# But ticker is cached
self.mod._STREAM_CACHE["AAPL"] = (
time.time(),
[{"agent": "test", "content": "cached result"}],
)
r = self.client.get("/stream/AAPL")
assert r.status_code == 200
assert "cached result" in r.text
# ── Quick signal caching ──────────────────────────────────────────────────────
class TestQuickCache:
def setup_method(self):
self.client, self.mod = _make_client()
self.mod._QUICK_CACHE.clear()
def test_cache_miss_calls_fuse_signals(self):
with patch.object(self.mod, "fuse_signals", return_value={"recommendation": "BUY"}) as mock:
self.client.get("/quick/TSLA")
mock.assert_called_once_with("TSLA")
def test_cache_hit_does_not_call_fuse_signals(self):
self.mod._QUICK_CACHE["NVDA"] = (time.time(), {"recommendation": "HOLD"})
with patch.object(self.mod, "fuse_signals") as mock:
r = self.client.get("/quick/NVDA")
mock.assert_not_called()
assert r.json()["recommendation"] == "HOLD"
def test_stale_cache_refreshes(self):
stale_ts = time.time() - (self.mod._QUICK_TTL + 60)
self.mod._QUICK_CACHE["GOOG"] = (stale_ts, {"recommendation": "OLD"})
with patch.object(self.mod, "fuse_signals", return_value={"recommendation": "SELL"}):
r = self.client.get("/quick/GOOG")
assert r.json()["recommendation"] == "SELL"
# ── Portfolio endpoint ────────────────────────────────────────────────────────
class TestPortfolioEndpoint:
def setup_method(self):
self.client, self.mod = _make_client()
def test_portfolio_returns_summary(self):
holdings = [
{"ticker": "AAPL", "name": "Apple", "sector": "Tech",
"asset_class": "Equity", "pct_of_portfolio": 10},
]
with patch.object(self.mod, "get_holdings", return_value=holdings), \
patch.object(self.mod, "fuse_signals", return_value={
"recommendation": "BUY", "confidence": 0.8,
"final_score": 0.7, "current_price": 190.0, "risk_flags": [],
}):
r = self.client.get("/portfolio")
assert r.status_code == 200
body = r.json()
assert "holdings" in body
assert "summary" in body
assert body["summary"]["total"] == 1
assert body["summary"]["buy"] == 1
def test_portfolio_handles_fuse_error(self):
holdings = [{"ticker": "BAD", "name": "Bad Co", "sector": "",
"asset_class": "", "pct_of_portfolio": 5}]
with patch.object(self.mod, "get_holdings", return_value=holdings), \
patch.object(self.mod, "fuse_signals", side_effect=Exception("connection error")):
r = self.client.get("/portfolio")
assert r.status_code == 200
assert r.json()["holdings"][0]["recommendation"] == "HOLD"
# ── HTTP method restrictions ──────────────────────────────────────────────────
class TestMethodRestrictions:
def setup_method(self):
self.client, _ = _make_client()
@pytest.mark.parametrize("path", ["/quick/AAPL", "/stream/AAPL", "/portfolio"])
def test_post_not_allowed(self, path):
r = self.client.post(path)
assert r.status_code == 405
@pytest.mark.parametrize("path", ["/quick/AAPL", "/stream/AAPL", "/portfolio"])
def test_delete_not_allowed(self, path):
r = self.client.delete(path)
assert r.status_code == 405