"""Tests for configuration management API endpoints. Tests the REST API endpoints for configuration management: - GET /api/config - Returns current configuration - POST /api/config - Saves and validates configuration - GET /api/config/schema - Returns JSON schema for form generation """ import json import tempfile from pathlib import Path from typing import Any from unittest.mock import patch import pytest import yaml from fastapi.testclient import TestClient from openclaw.dashboard.app import create_app from openclaw.core.config import ( ConfigLoader, CostStructure, LLMConfig, OpenClawConfig, SurvivalThresholds, get_config, reload_config, set_config, ) @pytest.fixture def app(): """Create a fresh FastAPI application for testing.""" return create_app() @pytest.fixture def client(app): """Create a TestClient for the FastAPI application.""" return TestClient(app) @pytest.fixture(autouse=True) def reset_config(): """Reset global config before and after each test.""" # Clear any existing config set_config(None) yield # Clean up after test set_config(None) @pytest.fixture def temp_config_file(tmp_path: Path) -> Path: """Create a temporary config file for testing.""" config_file = tmp_path / "test_config.yaml" config_data = { "initial_capital": {"trader": 15000.0, "analyst": 7500.0}, "simulation_days": 60, "log_level": "DEBUG", "cost_structure": { "llm_input_per_1m": 3.0, "llm_output_per_1m": 12.0, "market_data_per_call": 0.02, "trade_fee_rate": 0.002, }, "survival_thresholds": { "thriving_multiplier": 2.5, "stable_multiplier": 1.3, "struggling_multiplier": 0.7, "bankrupt_multiplier": 0.1, }, } config_file.write_text(yaml.dump(config_data)) return config_file @pytest.fixture def valid_config_payload() -> dict[str, Any]: """Return a valid configuration payload for API testing.""" return { "initial_capital": {"trader": 20000.0, "analyst": 10000.0, "risk_manager": 8000.0}, "simulation_days": 90, "log_level": "INFO", "data_dir": "./data", "cost_structure": { "llm_input_per_1m": 2.5, "llm_output_per_1m": 10.0, "market_data_per_call": 0.01, "trade_fee_rate": 0.001, }, "survival_thresholds": { "thriving_multiplier": 3.0, "stable_multiplier": 1.5, "struggling_multiplier": 0.8, "bankrupt_multiplier": 0.1, }, "llm_providers": { "openai": { "model": "gpt-4o", "temperature": 0.7, "timeout": 30, "api_key": None, "base_url": None, "max_tokens": None, } }, } class TestGetConfig: """Tests for GET /api/config endpoint.""" def test_get_config_returns_valid_config(self, client: TestClient) -> None: """Test that GET /api/config returns a valid configuration object.""" response = client.get("/api/config") assert response.status_code == 200 data = response.json() # Check required fields are present assert "initial_capital" in data assert "simulation_days" in data assert "log_level" in data assert "cost_structure" in data assert "survival_thresholds" in data def test_get_config_default_values(self, client: TestClient) -> None: """Test that GET /api/config returns default values when no config file exists.""" response = client.get("/api/config") assert response.status_code == 200 data = response.json() # Verify default values assert data["initial_capital"]["trader"] == 10000.0 assert data["initial_capital"]["analyst"] == 5000.0 assert data["simulation_days"] == 30 assert data["log_level"] == "INFO" assert data["cost_structure"]["llm_input_per_1m"] == 2.5 assert data["cost_structure"]["trade_fee_rate"] == 0.001 def test_get_config_loaded_from_file( self, client: TestClient, temp_config_file: Path ) -> None: """Test that GET /api/config returns config loaded from file.""" # Load the temp config file with patch.object(ConfigLoader, "_resolve_config_path", return_value=temp_config_file): set_config(None) # Clear cache response = client.get("/api/config") assert response.status_code == 200 data = response.json() # Verify values from file assert data["initial_capital"]["trader"] == 15000.0 assert data["simulation_days"] == 60 assert data["log_level"] == "DEBUG" class TestPostConfig: """Tests for POST /api/config endpoint.""" def test_post_valid_config_saves_successfully( self, client: TestClient, valid_config_payload: dict[str, Any] ) -> None: """Test that POST /api/config saves valid configuration.""" response = client.post("/api/config", json=valid_config_payload) assert response.status_code == 200 data = response.json() assert data["status"] == "success" assert "message" in data def test_post_config_updates_values( self, client: TestClient, valid_config_payload: dict[str, Any] ) -> None: """Test that POST /api/config updates configuration values.""" # Post new config response = client.post("/api/config", json=valid_config_payload) assert response.status_code == 200 # Verify values were updated by getting config response = client.get("/api/config") data = response.json() assert data["simulation_days"] == 90 assert data["initial_capital"]["trader"] == 20000.0 assert data["log_level"] == "INFO" def test_post_config_partial_update( self, client: TestClient ) -> None: """Test that POST /api/config handles partial updates.""" partial_config = {"simulation_days": 45, "log_level": "WARNING"} response = client.post("/api/config", json=partial_config) assert response.status_code == 200 # Verify only specified fields were updated response = client.get("/api/config") data = response.json() assert data["simulation_days"] == 45 assert data["log_level"] == "WARNING" # Other fields should retain default values assert data["initial_capital"]["trader"] == 10000.0 def test_post_invalid_config_rejected( self, client: TestClient ) -> None: """Test that POST /api/config rejects invalid configuration values.""" invalid_config = { "simulation_days": -5, # Invalid: must be positive } response = client.post("/api/config", json=invalid_config) # API returns 200 with error status instead of 422 assert response.status_code == 200 data = response.json() assert data.get("status") == "error" assert "simulation_days" in data.get("message", "").lower() or "validation" in data.get("message", "").lower() def test_post_invalid_log_level_rejected(self, client: TestClient) -> None: """Test that invalid log level is rejected.""" invalid_config = {"log_level": "INVALID"} response = client.post("/api/config", json=invalid_config) assert response.status_code == 200 data = response.json() assert data.get("status") == "error" def test_post_invalid_cost_structure_rejected(self, client: TestClient) -> None: """Test that invalid cost structure values are rejected.""" invalid_config = { "cost_structure": { "llm_input_per_1m": -1.0, # Invalid: must be positive } } response = client.post("/api/config", json=invalid_config) assert response.status_code == 200 data = response.json() assert data.get("status") == "error" def test_post_invalid_survival_thresholds_rejected(self, client: TestClient) -> None: """Test that invalid survival threshold values are rejected.""" invalid_config = { "survival_thresholds": { "thriving_multiplier": 0.5, # Invalid: must be > 1 } } response = client.post("/api/config", json=invalid_config) assert response.status_code == 200 data = response.json() assert data.get("status") == "error" def test_post_invalid_trade_fee_rate_rejected(self, client: TestClient) -> None: """Test that trade fee rate > 1 is rejected.""" invalid_config = { "cost_structure": { "trade_fee_rate": 1.5, # Invalid: must be <= 1 } } response = client.post("/api/config", json=invalid_config) assert response.status_code == 200 data = response.json() assert data.get("status") == "error" class TestGetConfigSchema: """Tests for GET /api/config/schema endpoint.""" def test_get_config_schema_returns_schema(self, client: TestClient) -> None: """Test that GET /api/config/schema returns JSON schema.""" response = client.get("/api/config/schema") assert response.status_code == 200 data = response.json() # Check schema structure assert "title" in data or "$defs" in data or "properties" in data def test_get_config_schema_contains_expected_fields(self, client: TestClient) -> None: """Test that schema contains expected configuration fields.""" response = client.get("/api/config/schema") assert response.status_code == 200 data = response.json() # Schema should contain properties or definitions schema_str = json.dumps(data).lower() assert "initial_capital" in schema_str or "initialcapital" in schema_str assert "simulation_days" in schema_str or "simulationdays" in schema_str assert "log_level" in schema_str or "loglevel" in schema_str class TestConfigRoundtrip: """Tests for config save/load roundtrip.""" def test_config_roundtrip( self, client: TestClient, valid_config_payload: dict[str, Any] ) -> None: """Test that config can be saved and then loaded with same values.""" # Save config response = client.post("/api/config", json=valid_config_payload) assert response.status_code == 200 # Load config and verify values match response = client.get("/api/config") data = response.json() assert data["simulation_days"] == valid_config_payload["simulation_days"] assert data["log_level"] == valid_config_payload["log_level"] assert data["initial_capital"]["trader"] == valid_config_payload["initial_capital"]["trader"] def test_multiple_updates_preserve_values( self, client: TestClient ) -> None: """Test that multiple config updates work correctly.""" # First update config1 = {"simulation_days": 30, "log_level": "INFO"} response = client.post("/api/config", json=config1) assert response.status_code == 200 # Second update config2 = {"simulation_days": 60} response = client.post("/api/config", json=config2) assert response.status_code == 200 # Verify final state response = client.get("/api/config") data = response.json() assert data["simulation_days"] == 60 assert data["log_level"] == "INFO" # Should retain from first update class TestConfigValidation: """Tests for configuration validation in API.""" def test_negative_initial_capital_rejected(self, client: TestClient) -> None: """Test that negative initial capital values are rejected.""" invalid_config = { "initial_capital": {"trader": -1000.0} } response = client.post("/api/config", json=invalid_config) assert response.status_code == 200 data = response.json() assert data.get("status") == "error" def test_zero_simulation_days_rejected(self, client: TestClient) -> None: """Test that zero simulation days is rejected.""" invalid_config = {"simulation_days": 0} response = client.post("/api/config", json=invalid_config) assert response.status_code == 200 data = response.json() assert data.get("status") == "error" def test_invalid_temperature_rejected(self, client: TestClient) -> None: """Test that invalid temperature values are rejected.""" invalid_config = { "llm_providers": { "openai": { "model": "gpt-4o", "temperature": 3.0, # Invalid: must be <= 2 } } } response = client.post("/api/config", json=invalid_config) assert response.status_code == 200 data = response.json() assert data.get("status") == "error" def test_nested_validation_error_detail(self, client: TestClient) -> None: """Test that validation errors include detail about nested fields.""" invalid_config = { "cost_structure": { "llm_input_per_1m": -5.0, } } response = client.post("/api/config", json=invalid_config) assert response.status_code == 200 data = response.json() # Should have error status with message assert data.get("status") == "error" assert "message" in data class TestErrorHandling: """Tests for API error handling.""" def test_malformed_json_rejected(self, client: TestClient) -> None: """Test that malformed JSON is handled gracefully.""" response = client.post( "/api/config", data="{invalid json", headers={"Content-Type": "application/json"}, ) assert response.status_code == 422 def test_empty_body_rejected(self, client: TestClient) -> None: """Test that empty request body is handled.""" response = client.post("/api/config", json={}) # Empty config should be valid (uses defaults) assert response.status_code == 200 def test_invalid_content_type_handled(self, client: TestClient) -> None: """Test that invalid content type is handled.""" response = client.post( "/api/config", data="not json", headers={"Content-Type": "text/plain"}, ) # FastAPI should return 415 Unsupported Media Type or 422 assert response.status_code in [415, 422]