Files
brain-of-reese/tests/unit/test_config.py
T
ducoterra c738105932 feat(web): customizable placeholder, footer text, and color theme via BOR_* env vars
BOR_INPUT_PLACEHOLDER / BOR_FOOTER_TEXT / BOR_THEME (+ the indigo.css example theme); authoring guide: frontend/assets/themes/README.md, docs: README 'Customizing the look'.
2026-09-01 12:04:06 -04:00

474 lines
19 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"
# --- 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()