"""Unit tests for the configuration loader. Tests pass ``_env_file=None`` and explicit field overrides so they are fully isolated from any real ``.env`` file or ambient environment variables. """ from __future__ import annotations from pathlib import Path from typing import Any import pytest from pydantic import ValidationError from doc_agent.config import ( MULTIMODAL_BACKENDS, ConfigError, Settings, load_config, ) def _load(**overrides: Any) -> Settings: """Load config in isolation from the ambient environment. Args: **overrides: Field overrides forwarded to ``load_config``. Returns: A validated ``Settings`` instance. """ return load_config(_env_file=None, **overrides) def test_valid_gemini_config_parses() -> None: """A Gemini backend with an API key and vision_direct parses cleanly.""" settings = _load( extraction_backend="gemini", gemini_api_key="test-key", image_strategy="vision_direct", ) assert settings.extraction_backend == "gemini" assert settings.gemini_api_key == "test-key" assert settings.image_strategy == "vision_direct" def test_valid_ollama_config_parses() -> None: """An Ollama backend with ocr_then_text needs no API key and parses.""" settings = _load( extraction_backend="ollama", image_strategy="ocr_then_text", ) assert settings.extraction_backend == "ollama" assert settings.image_strategy == "ocr_then_text" def test_gemini_requires_api_key() -> None: """Selecting Gemini without a key fails fast with an actionable message.""" with pytest.raises(ConfigError) as exc_info: _load( extraction_backend="gemini", gemini_api_key="", image_strategy="ocr_then_text", ) assert "GEMINI_API_KEY" in str(exc_info.value) def test_gemini_blank_api_key_is_rejected() -> None: """A whitespace-only key is treated as missing.""" with pytest.raises(ConfigError): _load( extraction_backend="gemini", gemini_api_key=" ", image_strategy="ocr_then_text", ) def test_vision_direct_requires_multimodal_backend() -> None: """vision_direct with a text-only backend fails fast with guidance.""" with pytest.raises(ConfigError) as exc_info: _load( extraction_backend="ollama", image_strategy="vision_direct", ) message = str(exc_info.value) assert "vision_direct" in message assert "ocr_then_text" in message def test_unknown_backend_is_rejected() -> None: """An unsupported backend name is rejected by the type constraint.""" with pytest.raises(ConfigError): _load(extraction_backend="openai", gemini_api_key="x") def test_confidence_threshold_out_of_range_is_rejected() -> None: """A threshold outside [0, 1] fails validation.""" with pytest.raises(ConfigError): _load( extraction_backend="ollama", image_strategy="ocr_then_text", confidence_threshold=1.5, ) def test_defaults_are_applied() -> None: """Unset optional fields fall back to the documented defaults.""" settings = _load( extraction_backend="ollama", image_strategy="ocr_then_text", ) assert settings.gemini_model == "gemini-flash-latest" assert settings.ollama_host == "http://localhost:11434" assert settings.ollama_model == "qwen2.5:7b" assert settings.confidence_threshold == pytest.approx(0.50) def test_paths_are_path_objects() -> None: """Path-typed settings are coerced to ``pathlib.Path``.""" settings = _load( extraction_backend="ollama", image_strategy="ocr_then_text", ) assert isinstance(settings.inbox_dir, Path) assert isinstance(settings.processed_dir, Path) assert isinstance(settings.review_dir, Path) assert isinstance(settings.export_dir, Path) assert isinstance(settings.db_path, Path) def test_settings_model_raises_validation_error_directly() -> None: """Constructing ``Settings`` directly surfaces a pydantic ValidationError.""" with pytest.raises(ValidationError): Settings( _env_file=None, extraction_backend="gemini", gemini_api_key="", image_strategy="ocr_then_text", ) def test_only_gemini_is_multimodal() -> None: """Ollama is not a multimodal backend; Gemini is.""" assert "gemini" in MULTIMODAL_BACKENDS assert "ollama" not in MULTIMODAL_BACKENDS