Files
brain-of-reese/tests/unit/test_config.py
T

282 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"