import pytest from datetime import datetime, timezone from unittest.mock import patch, AsyncMock from httpx import AsyncClient, ASGITransport from main import app @pytest.fixture async def client(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c @pytest.mark.asyncio async def test_health(client): resp = await client.get("/health") assert resp.status_code == 200 assert resp.json()["status"] == "ok" # --- Symbol Validation --- @pytest.mark.asyncio async def test_invalid_symbol_returns_400(client): resp = await client.get("/api/v1/stock/AAPL;DROP/quote") assert resp.status_code == 400 @pytest.mark.asyncio async def test_symbol_too_long_returns_422(client): resp = await client.get(f"/api/v1/stock/{'A' * 25}/quote") assert resp.status_code == 422 # --- Stock Endpoints --- @pytest.mark.asyncio @patch("routes.openbb_service.get_quote", new_callable=AsyncMock) async def test_stock_quote(mock_quote, client): mock_quote.return_value = { "name": "Apple Inc.", "last_price": 180.0, "change": 2.5, "change_percent": 1.4, "volume": 50000000, "market_cap": 2800000000000, "currency": "USD", } resp = await client.get("/api/v1/stock/AAPL/quote") assert resp.status_code == 200 data = resp.json() assert data["success"] is True assert data["data"]["price"] == 180.0 assert data["data"]["symbol"] == "AAPL" @pytest.mark.asyncio @patch("routes.openbb_service.get_profile", new_callable=AsyncMock) async def test_stock_profile(mock_profile, client): mock_profile.return_value = { "name": "Apple Inc.", "sector": "Technology", "industry": "Consumer Electronics", "country": "US", } resp = await client.get("/api/v1/stock/AAPL/profile") assert resp.status_code == 200 data = resp.json() assert data["data"]["sector"] == "Technology" @pytest.mark.asyncio @patch("routes.openbb_service.get_metrics", new_callable=AsyncMock) async def test_stock_metrics(mock_metrics, client): mock_metrics.return_value = {"pe_ratio": 28.5, "roe": 0.15} resp = await client.get("/api/v1/stock/AAPL/metrics") assert resp.status_code == 200 assert resp.json()["data"]["pe_ratio"] == 28.5 @pytest.mark.asyncio @patch("routes.openbb_service.get_historical", new_callable=AsyncMock) async def test_stock_historical(mock_hist, client): mock_hist.return_value = [ {"date": "2025-01-01", "open": 150, "close": 155, "volume": 1000000} ] resp = await client.get("/api/v1/stock/AAPL/historical?days=30") assert resp.status_code == 200 data = resp.json()["data"] assert len(data) == 1 @pytest.mark.asyncio @patch("routes.openbb_service.get_news", new_callable=AsyncMock) async def test_stock_news(mock_news, client): mock_news.return_value = [ {"title": "Apple reports earnings", "url": "https://example.com", "date": "2025-01-01"} ] resp = await client.get("/api/v1/stock/AAPL/news") assert resp.status_code == 200 assert len(resp.json()["data"]) == 1 @pytest.mark.asyncio @patch("routes.openbb_service.get_summary", new_callable=AsyncMock) async def test_stock_summary(mock_summary, client): mock_summary.return_value = { "quote": {"name": "Apple", "last_price": 180.0}, "profile": {"name": "Apple", "sector": "Tech"}, "metrics": {"pe_ratio": 28.0}, "financials": {"symbol": "AAPL", "income": [], "balance": [], "cash_flow": []}, } resp = await client.get("/api/v1/stock/AAPL/summary") assert resp.status_code == 200 data = resp.json()["data"] assert data["quote"]["price"] == 180.0 assert data["profile"]["sector"] == "Tech" # --- Error Handling --- @pytest.mark.asyncio @patch("routes.openbb_service.get_quote", new_callable=AsyncMock) async def test_upstream_error_returns_502(mock_quote, client): mock_quote.side_effect = RuntimeError("Connection failed") resp = await client.get("/api/v1/stock/AAPL/quote") assert resp.status_code == 502 assert "Data provider error" in resp.json()["detail"] # Verify raw exception is NOT leaked assert "Connection failed" not in resp.json()["detail"] # --- Portfolio Analysis --- @pytest.mark.asyncio @patch("routes.analysis_service.analyze_portfolio", new_callable=AsyncMock) async def test_portfolio_analyze(mock_analyze, client): from models import ( ActionEnum, AnalysisResult, ConfidenceEnum, HoldingAnalysis, PortfolioResponse, ) mock_analyze.return_value = PortfolioResponse( holdings=[ HoldingAnalysis( symbol="AAPL", current_price=180.0, buy_in_price=150.0, shares=100, pnl=3000.0, pnl_percent=0.20, analysis=AnalysisResult( action=ActionEnum.HOLD, confidence=ConfidenceEnum.MEDIUM, reasons=["Within hold range"], ), ) ], analyzed_at=datetime.now(timezone.utc), ) resp = await client.post( "/api/v1/portfolio/analyze", json={ "holdings": [ {"symbol": "AAPL", "shares": 100, "buy_in_price": 150.0} ] }, ) assert resp.status_code == 200 data = resp.json()["data"] assert len(data["holdings"]) == 1 assert data["holdings"][0]["analysis"]["action"] == "HOLD" @pytest.mark.asyncio async def test_portfolio_analyze_empty_body(client): resp = await client.post( "/api/v1/portfolio/analyze", json={"holdings": []}, ) assert resp.status_code == 422 # --- Discovery Endpoints --- @pytest.mark.asyncio @patch("routes.openbb_service.get_gainers", new_callable=AsyncMock) async def test_discover_gainers(mock_gainers, client): mock_gainers.return_value = [ {"symbol": "TSLA", "name": "Tesla", "price": 250.0, "change_percent": 5.0} ] resp = await client.get("/api/v1/discover/gainers") assert resp.status_code == 200 assert len(resp.json()["data"]) == 1 @pytest.mark.asyncio @patch("routes.openbb_service.get_losers", new_callable=AsyncMock) async def test_discover_losers(mock_losers, client): mock_losers.return_value = [ {"symbol": "XYZ", "name": "XYZ Corp", "price": 10.0, "change_percent": -8.0} ] resp = await client.get("/api/v1/discover/losers") assert resp.status_code == 200 assert len(resp.json()["data"]) == 1 @pytest.mark.asyncio @patch("routes.openbb_service.get_active", new_callable=AsyncMock) async def test_discover_active(mock_active, client): mock_active.return_value = [ {"symbol": "NVDA", "name": "NVIDIA", "volume": 99000000} ] resp = await client.get("/api/v1/discover/active") assert resp.status_code == 200 assert len(resp.json()["data"]) == 1 @pytest.mark.asyncio @patch("routes.openbb_service.get_undervalued", new_callable=AsyncMock) async def test_discover_undervalued(mock_uv, client): mock_uv.return_value = [{"symbol": "IBM", "name": "IBM"}] resp = await client.get("/api/v1/discover/undervalued") assert resp.status_code == 200 @pytest.mark.asyncio @patch("routes.openbb_service.get_growth", new_callable=AsyncMock) async def test_discover_growth(mock_growth, client): mock_growth.return_value = [{"symbol": "PLTR", "name": "Palantir"}] resp = await client.get("/api/v1/discover/growth") assert resp.status_code == 200