"""Tests for transcription.config — settings loading, provider defaults, and paths.""" import logging from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Any import pytest from pydantic import ValidationError from transcription.config import Provider from transcription.config import Settings from transcription.config import configure_logging from transcription.config import parse_cli_settings def _make_settings(**overrides: Any) -> Settings: """Build a Settings instance with a dummy API key, isolated from local env files.""" defaults: dict[str, Any] = {"openrouter_api_key": "test-key-abc123", "provider_models": None} defaults.update(overrides) return Settings(_env_file=None, **defaults) class TestSettingsLoading: """Verify Settings construction and required-field validation.""" def test_loads_from_env(self, monkeypatch): """Settings constructs when OPENROUTER_API_KEY is provided.""" monkeypatch.setenv("OPENROUTER_API_KEY", "test-key-xyz") settings = Settings() assert settings.openrouter_api_key.get_secret_value() == "test-key-xyz" def test_requires_api_key(self, monkeypatch): """Settings raises ValidationError when OPENROUTER_API_KEY is missing.""" monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) with pytest.raises(ValidationError): Settings(_env_file=None) def test_ignores_process_cli_arguments(self, monkeypatch): """Ordinary settings construction does not consume tooling arguments.""" monkeypatch.setattr("sys.argv", ["pytest", "--rootdir=/tmp/project"]) settings = _make_settings() assert settings.port == 8000 def test_explicit_cli_parser_reads_arguments(self): """The executable settings boundary accepts application CLI flags.""" settings = parse_cli_settings( [ "--openrouter-api-key", "test-key", "--port", "8123", "--reload", ] ) assert settings.openrouter_api_key.get_secret_value() == "test-key" assert settings.port == 8123 assert settings.reload is True class TestProviderSettings: """Verify provider enum defaults and validation.""" def test_defaults_to_openrouter(self): """Default provider is openrouter when not explicitly set.""" settings = _make_settings() assert settings.provider == Provider.OPENROUTER assert settings.provider == "openrouter" def test_rejects_invalid_value(self): """Setting PROVIDER to an invalid value raises ValidationError.""" with pytest.raises(ValidationError): _make_settings(provider="not-a-provider") def test_optional_provider_header_fields_default_to_none(self): """openrouter_http_referer and openrouter_app_title are None when unset.""" settings = _make_settings() assert settings.openrouter_http_referer is None assert settings.openrouter_app_title is None @pytest.mark.parametrize( ("field", "value"), [ ("transcription_temperature", -0.1), ("transcription_temperature", 2.1), ("transcription_top_p", -0.1), ("transcription_top_p", 1.1), ], ) def test_rejects_sampling_values_outside_provider_ranges(self, field, value): with pytest.raises(ValidationError): _make_settings(**{field: value}) def test_rejects_prompt_paths_outside_prompt_directory(self): with pytest.raises(ValidationError): _make_settings(default_prompt_name="../secret.md") def test_settings_are_immutable_runtime_snapshots(self): settings = _make_settings() with pytest.raises(ValidationError): settings.port = 9000 def test_provider_model_accepts_env_default(self, monkeypatch): """provider_model is sourced when provided through environment configuration.""" monkeypatch.setenv("PROVIDER_MODEL", "google/gemini-2.5-flash") settings = Settings(openrouter_api_key="test-key-abc123") assert settings.provider_model == "google/gemini-2.5-flash" def test_provider_models_defaults_to_default_model(self): settings = _make_settings(provider_model="vendor/default") assert settings.provider_models == ("vendor/default",) def test_provider_models_are_default_first_trimmed_and_deduplicated(self): settings = _make_settings( provider_model=" vendor/default ", provider_models=["vendor/alternate", "vendor/default", " vendor/other "], ) assert settings.provider_models == ("vendor/default", "vendor/alternate", "vendor/other") def test_provider_models_rejects_empty_list(self): with pytest.raises(ValidationError): _make_settings(provider_models=[]) def test_provider_models_loads_json_from_environment(self, monkeypatch): monkeypatch.setenv("PROVIDER_MODEL", "vendor/default") monkeypatch.setenv("PROVIDER_MODELS", '["vendor/alternate","vendor/default"]') settings = Settings(openrouter_api_key="test-key-abc123") assert settings.provider_models == ("vendor/default", "vendor/alternate") class TestPathSettings: """Verify filesystem path field types.""" def test_path_fields_are_path_objects(self): """upload_dir and prompt_dir are Path instances.""" settings = _make_settings() assert isinstance(settings.upload_dir, Path) assert isinstance(settings.prompt_dir, Path) assert isinstance(settings.log_dir, Path) def test_configure_logging_writes_rotating_file_logs_to_configured_directory(tmp_path): log_dir = tmp_path / "logs" settings = _make_settings(log_dir=log_dir, log_file_name="app.log") configure_logging(settings) root_handlers = logging.getLogger().handlers file_handlers = [handler for handler in root_handlers if isinstance(handler, RotatingFileHandler)] assert file_handlers assert Path(file_handlers[0].baseFilename) == log_dir / "app.log" class TestWorkerReliabilitySettings: """Verify worker retry settings defaults.""" def test_worker_retry_defaults(self): """worker retry settings default to no retries.""" settings = _make_settings() assert settings.run_embedded_worker is True assert settings.worker_max_retries == 0 assert settings.worker_stale_job_seconds == 90.0 assert settings.worker_retry_backoff_seconds == 1.0 assert settings.worker_shutdown_grace_seconds == 5.0 assert settings.worker_poll_interval_seconds == 1.0 def test_worker_stale_threshold_defaults_to_three_times_provider_timeout(self): settings = _make_settings(worker_provider_timeout_seconds=45.0) assert settings.worker_stale_job_seconds == 135.0 def test_worker_stale_threshold_derives_from_environment_timeout(self, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") monkeypatch.setenv("WORKER_PROVIDER_TIMEOUT_SECONDS", "45.0") monkeypatch.delenv("WORKER_STALE_JOB_SECONDS", raising=False) settings = Settings(_env_file=None) assert settings.worker_stale_job_seconds == 135.0 def test_worker_stale_threshold_must_exceed_provider_timeout(self): with pytest.raises(ValidationError): _make_settings(worker_provider_timeout_seconds=45.0, worker_stale_job_seconds=45.0) def test_provider_timeout_is_not_capped_at_twenty_seconds(): """HIGH-03: vision transcription regularly runs past the old le=20.0 ceiling.""" settings = Settings(openrouter_api_key="test-key", worker_provider_timeout_seconds=300.0) assert settings.worker_provider_timeout_seconds == 300.0 def test_provider_timeout_must_still_be_positive(): with pytest.raises(ValidationError): Settings(openrouter_api_key="test-key", worker_provider_timeout_seconds=0.0) def test_openrouter_client_timeout_tracks_the_configured_budget(): """HIGH-03: httpx defaults every phase to 5s, silently capping the provider call.""" from transcription.providers.openrouter import OpenRouterTranscriptionProvider settings = Settings(openrouter_api_key="test-key", worker_provider_timeout_seconds=123.0) provider = OpenRouterTranscriptionProvider(settings=settings) assert provider._capturing_client is not None timeout = provider._capturing_client._client.timeout assert timeout.read == 123.0 assert timeout.write == 123.0 assert timeout.pool == 123.0 assert timeout.connect == 10.0