blackopsrepl/portfolio-optimization-python
1
1"""
2Tests for REST API endpoints.
3
4Tests that configuration is properly received and applied.
5"""
6import pytest
7from fastapi.testclient import TestClient
8from portfolio_optimization.rest_api import app
9from portfolio_optimization.domain import (
10 PortfolioOptimizationPlanModel,
11 StockSelectionModel,
12 SolverConfigModel,
13)
14
15
16@pytest.fixture
17def client():
18 """Create a test client for the FastAPI app."""
19 return TestClient(app)
20
21
22class TestDemoDataEndpoints:
23 """Tests for demo data endpoints."""
24
25 def test_list_demo_data(self, client):
26 """GET /demo-data should return available datasets."""
27 response = client.get("/demo-data")
28 assert response.status_code == 200
29 data = response.json()
30 assert "SMALL" in data
31 assert "LARGE" in data
32
33 def test_get_small_demo_data(self, client):
34 """GET /demo-data/SMALL should return 25 stocks."""
35 response = client.get("/demo-data/SMALL")
36 assert response.status_code == 200
37 data = response.json()
38 assert "stocks" in data
39 assert len(data["stocks"]) == 25
40
41 def test_get_large_demo_data(self, client):
42 """GET /demo-data/LARGE should return 51 stocks."""
43 response = client.get("/demo-data/LARGE")
44 assert response.status_code == 200
45 data = response.json()
46 assert "stocks" in data
47 assert len(data["stocks"]) == 51
48
49
50class TestSolverConfigEndpoints:
51 """Tests for solver configuration handling."""
52
53 def test_plan_model_accepts_solver_config(self):
54 """PortfolioOptimizationPlanModel should accept solverConfig."""
55 model = PortfolioOptimizationPlanModel(
56 stocks=[
57 StockSelectionModel(
58 stockId="AAPL",
59 stockName="Apple",
60 sector="Technology",
61 predictedReturn=0.12,
62 selected=None
63 )
64 ],
65 targetPositionCount=20,
66 maxSectorPercentage=0.25,
67 solverConfig=SolverConfigModel(terminationSeconds=60)
68 )
69 assert model.solver_config is not None
70 assert model.solver_config.termination_seconds == 60
71
72 def test_plan_model_serializes_solver_config(self):
73 """solverConfig should serialize with camelCase aliases."""
74 model = PortfolioOptimizationPlanModel(
75 stocks=[],
76 solverConfig=SolverConfigModel(terminationSeconds=90)
77 )
78 data = model.model_dump(by_alias=True)
79 assert "solverConfig" in data
80 assert data["solverConfig"]["terminationSeconds"] == 90
81
82 def test_plan_model_deserializes_solver_config(self):
83 """solverConfig should deserialize from JSON."""
84 json_data = {
85 "stocks": [
86 {
87 "stockId": "AAPL",
88 "stockName": "Apple",
89 "sector": "Technology",
90 "predictedReturn": 0.12,
91 "selected": None
92 }
93 ],
94 "targetPositionCount": 15,
95 "maxSectorPercentage": 0.30,
96 "solverConfig": {
97 "terminationSeconds": 120
98 }
99 }
100 model = PortfolioOptimizationPlanModel.model_validate(json_data)
101 assert model.target_position_count == 15
102 assert model.max_sector_percentage == 0.30
103 assert model.solver_config is not None
104 assert model.solver_config.termination_seconds == 120
105
106 def test_plan_without_solver_config(self):
107 """Plan should work without solverConfig (uses defaults)."""
108 json_data = {
109 "stocks": [],
110 "targetPositionCount": 20,
111 "maxSectorPercentage": 0.25
112 }
113 model = PortfolioOptimizationPlanModel.model_validate(json_data)
114 assert model.solver_config is None # None is OK, will use default 30s
115
116 def test_post_portfolio_with_solver_config(self, client):
117 """POST /portfolios should accept solverConfig in request body."""
118 # First get demo data
119 demo_response = client.get("/demo-data/SMALL")
120 plan_data = demo_response.json()
121
122 # Add solver config
123 plan_data["solverConfig"] = {
124 "terminationSeconds": 10 # Use short time for test
125 }
126
127 # Submit for solving
128 response = client.post("/portfolios", json=plan_data)
129 assert response.status_code == 200
130 job_id = response.json()
131 assert job_id is not None
132 assert len(job_id) > 0
133
134 # Stop solving immediately (we just want to verify config was accepted)
135 stop_response = client.delete(f"/portfolios/{job_id}")
136 assert stop_response.status_code == 200
137 