from __future__ import annotations from pathlib import Path import pytest from app.core.config import ConfigurationError, load_settings CONFIG_ENV_NAMES = ( "APP_NAME", "APP_ENV", "APP_MODE", "MODELTF_ROUTE_PREFIX", "FRONTEND_PORT", "BACKEND_PORT", "DATABASE_URL", "DATABASE_BASE_URL", "DATABASE_USERNAME", "DATABASE_PASSWORD", "CORS_ALLOW_ORIGINS", "COMPUTE_MODE", "COMPUTE_STATUS_SYNC_MODE", "COMPUTE_POLL_INTERVAL_SECONDS", "LOG_LEVEL", "LOG_DIR", "LOG_FILE_PREFIX", "LOG_ERROR_FILE_PREFIX", "LOG_MAX_BYTES", "LOG_RETENTION_DAYS", ) def clear_config_env(monkeypatch: pytest.MonkeyPatch) -> None: for env_name in CONFIG_ENV_NAMES: monkeypatch.delenv(env_name, raising=False) def write_config(path: Path) -> None: path.write_text( """ app: name: YAML API route_prefix: /yaml-api cors_allow_origins: - http://yaml.example server: frontend_port: 18001 backend_port: 18002 database: url: postgresql+psycopg://db:5432/yaml username: yaml-user password: yaml-secret compute: mode: simulated poll_interval_seconds: 9 logging: directory: ./yaml-logs max_bytes: 1024 retention_days: 2 """.strip(), encoding="utf-8", ) def test_load_settings_from_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: clear_config_env(monkeypatch) config_path = tmp_path / "config.yaml" write_config(config_path) settings = load_settings(config_path) assert settings.app_name == "YAML API" assert settings.route_prefix == "/yaml-api" assert settings.frontend_port == 18001 assert settings.backend_port == 18002 assert settings.database_url == "postgresql+psycopg://yaml-user:yaml-secret@db:5432/yaml" assert settings.cors_allow_origins == ["http://yaml.example"] assert settings.compute_mode == "simulated" assert settings.compute_poll_interval_seconds == 9 assert settings.log_dir == str(tmp_path / "yaml-logs") assert settings.log_max_bytes == 1024 assert settings.log_retention_days == 2 def test_environment_overrides_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: clear_config_env(monkeypatch) config_path = tmp_path / "config.yaml" write_config(config_path) monkeypatch.setenv("DATABASE_URL", "postgresql+psycopg://env:secret@db:5432/env") monkeypatch.setenv("CORS_ALLOW_ORIGINS", "http://one.example,http://two.example") monkeypatch.setenv("COMPUTE_POLL_INTERVAL_SECONDS", "15") monkeypatch.setenv("FRONTEND_PORT", "19001") monkeypatch.setenv("BACKEND_PORT", "19002") settings = load_settings(config_path) assert settings.database_url == "postgresql+psycopg://env:secret@db:5432/env" assert settings.cors_allow_origins == ["http://one.example", "http://two.example"] assert settings.compute_poll_interval_seconds == 15 assert settings.frontend_port == 19001 assert settings.backend_port == 19002 def test_separate_database_credentials_are_encoded( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: clear_config_env(monkeypatch) config_path = tmp_path / "config.yaml" write_config(config_path) monkeypatch.setenv("DATABASE_USERNAME", "user@example.com") monkeypatch.setenv("DATABASE_PASSWORD", "secret:/?#[]@") settings = load_settings(config_path) assert settings.database_url == ( "postgresql+psycopg://user%40example.com:secret%3A%2F%3F%23%5B%5D%40@db:5432/yaml" ) def test_explicit_missing_config_is_rejected(tmp_path: Path) -> None: with pytest.raises(ConfigurationError, match="does not exist"): load_settings(tmp_path / "missing.yaml") def test_default_cors_follows_frontend_port( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: clear_config_env(monkeypatch) config_path = tmp_path / "config.yaml" config_path.write_text( "server:\n frontend_port: 28001\n backend_port: 28002\n", encoding="utf-8", ) settings = load_settings(config_path) assert settings.cors_allow_origins == [ "http://localhost:28001", "http://127.0.0.1:28001", ] @pytest.mark.parametrize("frontend_port", [0, 65536, "invalid", True]) def test_invalid_server_port_is_rejected( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, frontend_port: object, ) -> None: clear_config_env(monkeypatch) config_path = tmp_path / "config.yaml" config_path.write_text( "server:\n" f" frontend_port: {str(frontend_port).lower()}\n" " backend_port: 28002\n", encoding="utf-8", ) with pytest.raises(ConfigurationError, match="server.frontend_port"): load_settings(config_path) def test_frontend_and_backend_ports_must_differ( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: clear_config_env(monkeypatch) config_path = tmp_path / "config.yaml" config_path.write_text( "server:\n frontend_port: 28001\n backend_port: 28001\n", encoding="utf-8", ) with pytest.raises(ConfigurationError, match="must be different"): load_settings(config_path) @pytest.mark.parametrize( ("database_base_url", "message"), [ ("mysql://localhost:3306/yg_ft", "must use postgresql"), ("postgresql+psycopg://localhost:5432", "must include a database name"), ], ) def test_invalid_database_url_is_rejected( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, database_base_url: str, message: str, ) -> None: clear_config_env(monkeypatch) config_path = tmp_path / "config.yaml" write_config(config_path) monkeypatch.setenv("DATABASE_BASE_URL", database_base_url) with pytest.raises(ConfigurationError, match=message): load_settings(config_path)