Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |
| 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" | |
| 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) | |
| 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() | |
| def test_post_not_allowed(self, path): | |
| r = self.client.post(path) | |
| assert r.status_code == 405 | |
| def test_delete_not_allowed(self, path): | |
| r = self.client.delete(path) | |
| assert r.status_code == 405 | |