"""Unit tests: settings defaults & env overrides.""" from __future__ import annotations import json from typing import Any import pytest from pydantic import ValidationError from pydantic_settings import SettingsError from app.config import Settings def _settings(**kwargs: Any) -> Settings: """Build Settings without reading a .env file (deterministic tests).""" kwargs.setdefault("_env_file", None) return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime) def test_defaults_match_locked_decisions(monkeypatch: pytest.MonkeyPatch) -> None: # The test process sets BOR_RELEVANCE_THRESHOLD=0.30 for the mock- # calibrated in-process suites (see tests/conftest.py) — the *default* # under test is the production one. monkeypatch.delenv("BOR_RELEVANCE_THRESHOLD", raising=False) s = _settings() assert s.llm_chat_model == "turbo" assert s.llm_embed_model == "embed" assert s.embedding_dim == 768 assert s.llm_base_url.endswith("/v1") # A8 (revised): the honesty gate input is the best cosine, default 0.62. assert s.relevance_threshold == 0.62 # A7 (revised): hybrid retrieval — cosine top-N ∪ FTS top-N, RRF-fused. assert s.hybrid_vector_candidates >= 1 assert s.hybrid_lexical_candidates >= 1 assert s.rrf_k >= 1 assert s.top_n_docs >= 1 # Owner instruction 2026-08-22: answers may run up to 32 768 tokens. assert s.max_output_tokens == 32_768 # Phase 17: the model's thinking streams by default (kill-switch off). assert s.stream_thinking is True assert len(s.suggestions) >= 3 # A9 (revised): the import scope covers the seven A9 formats. assert s.import_extension_set == { ".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py" } def test_env_override(monkeypatch) -> None: monkeypatch.setenv("BOR_RELEVANCE_THRESHOLD", "0.42") monkeypatch.setenv("BOR_LLM_CHAT_MODEL", "juggernaut") s = _settings() assert s.relevance_threshold == 0.42 assert s.llm_chat_model == "juggernaut" def test_max_output_tokens_env_override(monkeypatch) -> None: monkeypatch.setenv("BOR_MAX_OUTPUT_TOKENS", "1234") s = _settings() assert s.max_output_tokens == 1234 def test_stream_thinking_default_true_and_env_parse(monkeypatch: pytest.MonkeyPatch) -> None: """Phase 17 kill-switch (``BOR_STREAM_THINKING``): on by default, ``0``/``false`` turn the ``thinking`` SSE frames off.""" assert _settings().stream_thinking is True assert _settings(stream_thinking=False).stream_thinking is False monkeypatch.setenv("BOR_STREAM_THINKING", "0") assert _settings().stream_thinking is False monkeypatch.setenv("BOR_STREAM_THINKING", "false") assert _settings().stream_thinking is False monkeypatch.setenv("BOR_STREAM_THINKING", "1") assert _settings().stream_thinking is True def test_import_extensions_env_override_is_a_csv_list(monkeypatch) -> None: monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,yml") s = _settings() assert s.import_extension_set == {".md", ".yml"} def test_import_extensions_rejects_unknown_format(monkeypatch) -> None: """A typo in the CSV fails at startup (loudly), not by silently walking zero files.""" monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,docx") with pytest.raises(ValidationError, match="docx"): _settings() def test_import_extensions_rejects_empty(monkeypatch) -> None: monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", " ") with pytest.raises(ValidationError): _settings() def test_suggestions_default_is_three_plus_real_questions() -> None: s = _settings() assert len(s.suggestions) >= 3 assert all(isinstance(q, str) and q.strip() for q in s.suggestions) # Distinct chips only — duplicates in the onboarding row are noise. assert len({q.strip().lower() for q in s.suggestions}) == len(s.suggestions) def test_suggestions_env_override_is_json_list(monkeypatch) -> None: override = [ "How do I back up with Borg?", "How is my K3S cluster set up?", "How do I deploy a service?", ] monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(override)) s = _settings() assert s.suggestions == override def test_suggestions_malformed_json_fails_loudly(monkeypatch) -> None: monkeypatch.setenv("BOR_SUGGESTIONS", "[not valid json") with pytest.raises(SettingsError): _settings() def test_effective_api_key_fallback(monkeypatch) -> None: monkeypatch.delenv("AIPI_KEY", raising=False) s = _settings() assert s.effective_api_key == "not-needed" monkeypatch.setenv("AIPI_KEY", "sk-from-env") s2 = _settings() assert s2.effective_api_key == "sk-from-env"