"""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 _DEFAULT_IMPORT_EXTENSIONS, Settings # pyright: ignore[reportPrivateUsage] 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" # A5 extended (phase 30): one-shot completions default to the ``lite`` # model on the same endpoint. assert s.llm_summary_model == "lite" 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 2026-08-27): the import scope covers all seventeen # A9 formats (original seven + quadlet family + jinja). assert s.import_extension_set == { ".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py", ".container", ".network", ".volume", ".image", ".pod", ".kube", ".swap", ".os", ".endpoint", ".j2", } NEW_A9_FORMATS = ( "container", "network", "volume", "image", "pod", "kube", "swap", "os", "endpoint", "j2", ) def test_default_import_extensions_is_the_full_a9_family() -> None: """Phase 56: the built-in default is the full A9 set — the original seven plus the ten added 2026-08-27 (quadlet family + ``j2``). It is the default and the ``.env.example`` example, NOT a ceiling: the validator accepts any well-formed extension beyond it.""" assert { "md", "markdown", "txt", "yaml", "yml", "json", "py", *NEW_A9_FORMATS, } == _DEFAULT_IMPORT_EXTENSIONS def test_default_import_extensions_include_the_ten_new_formats() -> None: """A9 revised 2026-08-27 (owner permission): the quadlet family + ``j2`` are imported by default — no env configuration needed — with the original seven first (order is cosmetic, the set is what matters).""" s = _settings() for ext in NEW_A9_FORMATS: assert ext in s.import_extensions assert s.import_extension_set == ( {".md", ".markdown", ".txt", ".yaml", ".yml", ".json", ".py"} | {f".{ext}" for ext in NEW_A9_FORMATS} ) 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_llm_summary_model_env_override(monkeypatch) -> None: """Phase 30: ``BOR_LLM_SUMMARY_MODEL`` overrides the ``lite`` default""" monkeypatch.setenv("BOR_LLM_SUMMARY_MODEL", "mini") s = _settings() assert s.llm_summary_model == "mini" def test_summary_max_chars_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None: """Phase 30: document content sent to the ``lite`` model is capped at ``BOR_SUMMARY_MAX_CHARS`` (default 12 000 chars per call).""" monkeypatch.delenv("BOR_SUMMARY_MAX_CHARS", raising=False) assert _settings().summary_max_chars == 12_000 monkeypatch.setenv("BOR_SUMMARY_MAX_CHARS", "5000") assert _settings().summary_max_chars == 5000 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_agent_max_rounds_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None: """Phase 45: the per-tool budgets are gone — ``BOR_AGENT_MAX_ROUNDS`` (default 10) is the single agent-loop knob; ``0`` is the no-tools kill switch.""" monkeypatch.delenv("BOR_AGENT_MAX_ROUNDS", raising=False) assert _settings().agent_max_rounds == 10 monkeypatch.setenv("BOR_AGENT_MAX_ROUNDS", "5") assert _settings().agent_max_rounds == 5 monkeypatch.setenv("BOR_AGENT_MAX_ROUNDS", "0") assert _settings().agent_max_rounds == 0 def test_agent_max_rounds_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None: """``0`` is the kill switch — a negative value is a typo, so the validator fails loudly at startup.""" monkeypatch.setenv("BOR_AGENT_MAX_ROUNDS", "-1") with pytest.raises(ValidationError, match="agent_max_rounds"): _settings() 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_accepts_novel_extension(monkeypatch) -> None: """Phase 56 (owner permission 2026-08-31): the A9 family is the default, not the ceiling — a novel well-formed extension (``sh``) is accepted and simply becomes importable.""" monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,sh") s = _settings() assert s.import_extension_set == {".md", ".sh"} def test_import_extensions_normalizes_case_and_leading_dot(monkeypatch) -> None: """Case and a leading dot are both tolerated (unchanged tolerance).""" monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "MD,.Py") s = _settings() assert s.import_extension_set == {".md", ".py"} def test_import_extensions_rejects_empty(monkeypatch: pytest.MonkeyPatch) -> None: """A blank list would silently import nothing — fail loudly at startup, naming the field (empty, whitespace-only, and comma-only all parse to zero formats).""" for value in ("", " ", ",,"): monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", value) with pytest.raises(ValidationError, match="import_extensions"): _settings() def test_import_extensions_validator_accepts_new_a9_formats(monkeypatch) -> None: """A9 revised 2026-08-27: quadlet/jinja names are first-class default formats — a CSV using them (a narrowing of the default family) is accepted.""" monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,container,j2") s = _settings() assert s.import_extension_set == {".md", ".container", ".j2"} def test_import_extensions_rejects_malformed_tokens(monkeypatch: pytest.MonkeyPatch) -> None: """The shape guard (``^[a-z0-9]{1,16}$``) is the typo guard — it keeps punctuation and path-ish values out of the set, naming the offending token(s), while any extension a file could actually be suffixed with still goes through.""" monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,sh!") with pytest.raises(ValidationError, match="sh!"): _settings() monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,../x") with pytest.raises(ValidationError, match=r"/x"): _settings() def test_git_sources_default_empty_and_sources_dir_default() -> None: """Phase 28: no git sources by default (backwards-compatible with the ``--source`` / ``DEFAULT_SOURCES`` fallback); the clone location stays a raw string (``~`` is expanded by the import script, not the setting).""" s = _settings() assert s.git_sources == "" assert s.git_source_list == [] assert s.sources_dir == "~/bor-sources" def test_git_sources_env_override_parses_comma_separated_list( monkeypatch: pytest.MonkeyPatch, ) -> None: """``BOR_GIT_SOURCES`` is a raw CSV: entries are trimmed and empty entries dropped; URLs are stored untouched (no scheme parsing here).""" monkeypatch.setenv( "BOR_GIT_SOURCES", "https://github.com/user/homelab.git, " " git@github.com:user/deployments.git ,, https://git.reeseapps.com/x/y.git ", ) s = _settings() # The raw CSV string is preserved untouched (no parsing in the setting). assert s.git_sources == ( "https://github.com/user/homelab.git, " " git@github.com:user/deployments.git ,, https://git.reeseapps.com/x/y.git " ) assert s.git_source_list == [ "https://github.com/user/homelab.git", "git@github.com:user/deployments.git", "https://git.reeseapps.com/x/y.git", ] def test_git_sources_whitespace_only_yields_empty_list(monkeypatch) -> None: """A configured-but-blank value behaves the same as unset: no git sources, so the script falls back to its legacy local defaults.""" monkeypatch.setenv("BOR_GIT_SOURCES", " , , ") s = _settings() assert s.git_source_list == [] def test_sources_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BOR_SOURCES_DIR", "/data/bor/sources") s = _settings() assert s.sources_dir == "/data/bor/sources" 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" # --- Docs push (phase 59) --- def test_docs_push_defaults_are_inert() -> None: """Phase 59, D3: no docs repo by default — the feature is inert-by-default (button hidden, push endpoint 409s — the optional-feature pattern of the git-sources env fallback), and the branch/base defaults + raw work-dir string are in place.""" s = _settings() assert s.docs_repo == "" assert s.docs_configured is False assert s.docs_branch == "bor-docs" assert s.docs_base_branch == "main" # Raw string on purpose — Path.expanduser() is applied by the push # service, not the setting (the sources_dir/upload_dir convention). assert s.docs_work_dir == "~/bor-docs" def test_docs_repo_set_is_configured(monkeypatch: pytest.MonkeyPatch) -> None: """A non-empty ``BOR_DOCS_REPO`` turns the feature on — a URL or a local path (D3: generic remote, no scheme parsing here).""" for repo in ("/path/to/docs-repo", "https://git.example.com/docs.git"): monkeypatch.setenv("BOR_DOCS_REPO", repo) s = _settings() assert s.docs_configured is True assert s.docs_repo == repo # Whitespace-only behaves like empty: still inert. monkeypatch.setenv("BOR_DOCS_REPO", " ") assert _settings().docs_configured is False def test_docs_branch_env_override(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("BOR_DOCS_BRANCH", raising=False) monkeypatch.delenv("BOR_DOCS_BASE_BRANCH", raising=False) assert _settings().docs_branch == "bor-docs" assert _settings().docs_base_branch == "main" monkeypatch.setenv("BOR_DOCS_BRANCH", "docs-pr") monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "master") s = _settings() assert s.docs_branch == "docs-pr" assert s.docs_base_branch == "master" def test_docs_work_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BOR_DOCS_WORK_DIR", "/data/bor/docs") s = _settings() assert s.docs_work_dir == "/data/bor/docs" def test_docs_branch_whitespace_fails_loudly_when_repo_set( monkeypatch: pytest.MonkeyPatch, ) -> None: """A whitespace-bearing branch would corrupt a ``git checkout`` argument — fail loud at startup, naming the field (the ``agent_max_rounds`` pattern).""" monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo") monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs") with pytest.raises(ValidationError, match="docs_branch"): _settings() def test_docs_branch_dotdot_fails_loudly_when_repo_set( monkeypatch: pytest.MonkeyPatch, ) -> None: """``..`` is a path-traversal token, never part of a branch name. A blank branch is rejected too (empty while a repo is set).""" monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo") monkeypatch.setenv("BOR_DOCS_BRANCH", "a..b") with pytest.raises(ValidationError, match="docs_branch"): _settings() monkeypatch.setenv("BOR_DOCS_BRANCH", " ") with pytest.raises(ValidationError, match="docs_branch"): _settings() def test_docs_base_branch_invalid_fails_loudly_naming_field( monkeypatch: pytest.MonkeyPatch, ) -> None: """The base branch gets the same token shape check — the error names ``docs_base_branch``, not the sibling field.""" monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo") monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "bad branch") with pytest.raises(ValidationError, match="docs_base_branch"): _settings() monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "a..b") with pytest.raises(ValidationError, match="docs_base_branch"): _settings() def test_docs_branchs_valid_when_repo_set(monkeypatch: pytest.MonkeyPatch) -> None: """Repo set + well-formed branch tokens boot cleanly and the feature is configured (dash/dot/slash branch names are legal git refs and stay accepted).""" monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo") s = _settings() # defaults bor-docs / main assert s.docs_configured is True monkeypatch.setenv("BOR_DOCS_BRANCH", "feature/docs-update") monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "develop") s2 = _settings() assert s2.docs_configured is True assert s2.docs_branch == "feature/docs-update" assert s2.docs_base_branch == "develop" def test_docs_branchs_garbage_ignored_when_repo_unset( monkeypatch: pytest.MonkeyPatch, ) -> None: """All-or-nothing: while the repo is empty the feature is inert, so the (ignored) branch values must NOT block startup — only a configured repo makes the shape check apply.""" monkeypatch.delenv("BOR_DOCS_REPO", raising=False) monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs..") monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "..") s = _settings() assert s.docs_configured is False assert s.docs_branch == "bor docs.." # stored verbatim, never used # --- UI customization (phase 62, TODO L3) --- def test_ui_customization_defaults_are_the_phase_61_copy() -> None: """UNSET => byte-identical to the phase-61 neutral UI: the locked phase-61 copy is the DEFAULT (composer placeholder + footer line), and an empty theme = the built-in dark-tech palette.""" s = _settings() assert s.input_placeholder == "Ask me anything…" assert s.footer_text == "Powered by self-hosted models" assert s.theme == "" def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None: """The three settings honor their ``BOR_`` env vars (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``); placeholder/footer accept any string (empty is legal — the brand layer then keeps the template default).""" monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "Ask the vault…") monkeypatch.setenv("BOR_FOOTER_TEXT", "Powered by my own models") monkeypatch.setenv("BOR_THEME", "indigo.css") s = _settings() assert s.input_placeholder == "Ask the vault…" assert s.footer_text == "Powered by my own models" assert s.theme == "indigo.css" monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "") assert _settings().input_placeholder == "" # empty stands def test_theme_validator_accepts_empty_and_bare_css_filename() -> None: """Phase 62 (A5): empty = the built-in palette; a bare lowercase ``.css`` filename (the ``indigo.css`` example) is the only non-empty shape — dashes/underscores/digits are legal tokens.""" assert _settings().theme == "" # "" passes assert _settings(theme="indigo.css").theme == "indigo.css" assert _settings(theme="dark-2026_v2.css").theme == "dark-2026_v2.css" @pytest.mark.parametrize( ("bad", "match"), [ # uppercase — the shape is lowercase-only ("Indigo.css", "Indigo.css"), # path escape — a theme is a filename, never a path ("../evil.css", r"\.\./evil\.css"), ("a/b.css", r"a/b\.css"), ("/abs.css", r"got '/abs\.css'"), # a missing extension is not a theme file ("indigo", r"got 'indigo'"), # must not match the example text ], ) def test_theme_validator_rejects_malformed_naming_the_value( bad: str, match: str, ) -> None: """A typo in ``BOR_THEME`` must kill startup, not silently 404 at runtime — the rejection names the offending value (the phase-56 fail-loud house style) alongside the allowed shape.""" with pytest.raises(ValidationError, match=match): _settings(theme=bad) def test_bor_theme_env_malformed_fails_startup_naming_value( monkeypatch: pytest.MonkeyPatch, ) -> None: """The startup path: a malformed ``BOR_THEME`` in the environment fails Settings construction loudly (the app builds its settings at import time, so this is a refused boot), naming the value — the E2E boots-check lands in task 05.""" monkeypatch.setenv("BOR_THEME", "../evil.css") with pytest.raises(ValidationError, match=r"\.\./evil\.css"): _settings()