All gates green — no defects found; this pass was verification only. **Phase 113 final verification pass — report** - Verified (no code changes needed): `select_documents_tiered` cited/related tiering + `select_documents` wrapper, `TurnPlan.related_docs`, `ChatDoneEvent.related` (additive, old payloads parse), `appendRelated` UI row (`.related-doc`, never `.source-chip`), done-frame + restore-path wiring, two settings with validators, `.env.example` entries - `uv run pytest --cov=app --cov-report=term-missing` → 2422 passed, app/ coverage **99%** (>90% gate) - `uv run pytest tests/e2e/test_source_chip_quality.py -v --no-cov` (isolated) → 2 passed - Regression E2E `test_retrieval_quality.py` + `test_honest_deflection.py` + `test_chat_rag.py` + `test_sources_midstream_bug.py` → 17 passed - `uv run ruff check . && uv run pyright` → clean (0 errors); `bash .agents/validate.sh` → "validation OK" Completion criteria: 1. Single-doc question → exactly one `.source-chip` (E2E): ✅ passed 2. Weak 2nd doc only in de-emphasized related row, never `.source-chip` (unit + E2E): ✅ passed 3. Deflected turn → zero citation chips, weak hits in related row: ✅ passed 4. Full suite green, coverage >90%, isolated E2E green, lint/types clean: ✅ passed 5. `--no-gpg-sign` commit + phase dir move: left to harness per pass rules (task files already in `complete/`) No deviations. Next pending phase: `114_embed_question_length`.
646 lines
27 KiB
Python
646 lines
27 KiB
Python
"""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
|
||
# Phase 106, D6: the recency boost is ON by default (0.0007 — the
|
||
# fine-line-tuned value, task 07) with a 365-day decay timescale.
|
||
assert s.recency_boost == 0.0007
|
||
assert s.recency_half_life_days == 365
|
||
# 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_llm_retry_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Phase 67: a failed LLM request is retried by default — 3 retries
|
||
with a flat 5 s delay (the TODO-locked values, no backoff)."""
|
||
monkeypatch.delenv("BOR_LLM_RETRIES", raising=False)
|
||
monkeypatch.delenv("BOR_LLM_RETRY_DELAY", raising=False)
|
||
s = _settings()
|
||
assert s.llm_retries == 3
|
||
assert s.llm_retry_delay == 5.0
|
||
|
||
|
||
def test_llm_retry_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""``BOR_LLM_RETRIES`` / ``BOR_LLM_RETRY_DELAY`` override the defaults;
|
||
``0`` retries is the no-retry kill switch (pre-phase-67 behavior)."""
|
||
monkeypatch.setenv("BOR_LLM_RETRIES", "0")
|
||
monkeypatch.setenv("BOR_LLM_RETRY_DELAY", "1.5")
|
||
s = _settings()
|
||
assert s.llm_retries == 0
|
||
assert s.llm_retry_delay == 1.5
|
||
|
||
|
||
def test_llm_retries_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""``0`` is the kill switch — a negative value is a typo, so the
|
||
validator fails loudly at startup (the ``agent_max_rounds`` pattern)."""
|
||
monkeypatch.setenv("BOR_LLM_RETRIES", "-1")
|
||
with pytest.raises(ValidationError, match="llm_retries"):
|
||
_settings()
|
||
|
||
|
||
def test_llm_retry_delay_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A negative delay is a typo — fail loudly at startup."""
|
||
monkeypatch.setenv("BOR_LLM_RETRY_DELAY", "-0.5")
|
||
with pytest.raises(ValidationError, match="llm_retry_delay"):
|
||
_settings()
|
||
|
||
|
||
def test_history_budget_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Phase 74 (TODO L4): the client-provided history is trimmed
|
||
newest-first against the newest 40 turns within a total of
|
||
24 000 chars (text + prior thinking, owner-locked A3)."""
|
||
monkeypatch.delenv("BOR_HISTORY_MAX_TURNS", raising=False)
|
||
monkeypatch.delenv("BOR_HISTORY_MAX_CHARS", raising=False)
|
||
s = _settings()
|
||
assert s.history_max_turns == 40
|
||
assert s.history_max_chars == 24_000
|
||
|
||
|
||
def test_history_budget_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""``BOR_HISTORY_MAX_TURNS`` / ``BOR_HISTORY_MAX_CHARS`` override the
|
||
defaults; ``0`` on either is the no-history kill switch (the
|
||
pre-phase-74 two-message requests)."""
|
||
monkeypatch.setenv("BOR_HISTORY_MAX_TURNS", "12")
|
||
monkeypatch.setenv("BOR_HISTORY_MAX_CHARS", "5000")
|
||
s = _settings()
|
||
assert s.history_max_turns == 12
|
||
assert s.history_max_chars == 5000
|
||
monkeypatch.setenv("BOR_HISTORY_MAX_TURNS", "0")
|
||
assert _settings().history_max_turns == 0
|
||
monkeypatch.setenv("BOR_HISTORY_MAX_CHARS", "0")
|
||
assert _settings().history_max_chars == 0
|
||
|
||
|
||
def test_history_max_turns_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""``0`` is the no-history kill switch — a negative value is a typo,
|
||
so the validator fails loudly at startup (the ``agent_max_rounds``
|
||
pattern)."""
|
||
monkeypatch.setenv("BOR_HISTORY_MAX_TURNS", "-1")
|
||
with pytest.raises(ValidationError, match="history_max_turns"):
|
||
_settings()
|
||
|
||
|
||
def test_history_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A negative char budget is a typo — fail loudly at startup."""
|
||
monkeypatch.setenv("BOR_HISTORY_MAX_CHARS", "-1")
|
||
with pytest.raises(ValidationError, match="history_max_chars"):
|
||
_settings()
|
||
|
||
|
||
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_source_usefulness_floor_default_and_env_override(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 113 (LOCKED A2): the citation-slot bar — default 0.35 (the
|
||
same bar as the A8 lexical support floor), env-tunable, ``0`` = the
|
||
no-bar kill switch."""
|
||
monkeypatch.delenv("BOR_SOURCE_USEFULNESS_FLOOR", raising=False)
|
||
# The test process pins the mock-calibrated threshold (0.30, see
|
||
# tests/conftest.py) — clear it so the PRODUCTION default pair
|
||
# (0.62 / 0.35) is what the validator sees.
|
||
monkeypatch.delenv("BOR_RELEVANCE_THRESHOLD", raising=False)
|
||
assert _settings().source_usefulness_floor == 0.35
|
||
monkeypatch.setenv("BOR_SOURCE_USEFULNESS_FLOOR", "0.5")
|
||
assert _settings().source_usefulness_floor == 0.5
|
||
monkeypatch.setenv("BOR_SOURCE_USEFULNESS_FLOOR", "0")
|
||
assert _settings().source_usefulness_floor == 0.0
|
||
|
||
|
||
def test_source_usefulness_floor_at_threshold_is_legal(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""The validator bound is inclusive (>=): a bar exactly at the
|
||
relevance threshold is legal — the gate and the bar agree on every
|
||
grounded document."""
|
||
monkeypatch.delenv("BOR_SOURCE_USEFULNESS_FLOOR", raising=False)
|
||
monkeypatch.delenv("BOR_RELEVANCE_THRESHOLD", raising=False)
|
||
s = _settings(relevance_threshold=0.62, source_usefulness_floor=0.62)
|
||
assert s.source_usefulness_floor == 0.62
|
||
|
||
|
||
def test_source_usefulness_floor_rejects_above_threshold(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A bar above the relevance threshold would demote to the related
|
||
tier documents the gate itself calls grounded — a typo, so the
|
||
validator fails loudly at startup (the lexical_support_floor guard)."""
|
||
monkeypatch.delenv("BOR_SOURCE_USEFULNESS_FLOOR", raising=False)
|
||
with pytest.raises(ValidationError, match="source_usefulness_floor"):
|
||
_settings(relevance_threshold=0.62, source_usefulness_floor=0.70)
|
||
|
||
|
||
def test_source_usefulness_floor_rejects_negative(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A negative bar is a typo (the agent_max_rounds pattern)."""
|
||
with pytest.raises(ValidationError, match="source_usefulness_floor"):
|
||
_settings(source_usefulness_floor=-0.1)
|
||
|
||
|
||
def test_related_max_docs_default_and_env_override(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 113 (LOCKED A4): the related-doc tier cap — default 2,
|
||
``0`` = the no-related-docs kill switch."""
|
||
monkeypatch.delenv("BOR_RELATED_MAX_DOCS", raising=False)
|
||
assert _settings().related_max_docs == 2
|
||
monkeypatch.setenv("BOR_RELATED_MAX_DOCS", "0")
|
||
assert _settings().related_max_docs == 0
|
||
|
||
|
||
def test_related_max_docs_rejects_negative(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A negative cap is a typo (the agent_max_rounds pattern)."""
|
||
monkeypatch.setenv("BOR_RELATED_MAX_DOCS", "-1")
|
||
with pytest.raises(ValidationError, match="related_max_docs"):
|
||
_settings()
|
||
|
||
|
||
def test_read_max_chars_default_and_env_override(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 95: the agent ``read`` tool's result is capped at
|
||
``BOR_READ_MAX_CHARS`` (default 128 000 chars ≈ 32k tokens — a
|
||
quarter of the owner's 128k-token minimum context). Env-tunable in
|
||
both directions."""
|
||
monkeypatch.delenv("BOR_READ_MAX_CHARS", raising=False)
|
||
assert _settings().read_max_chars == 128_000
|
||
monkeypatch.setenv("BOR_READ_MAX_CHARS", "5000")
|
||
assert _settings().read_max_chars == 5000
|
||
# ``0`` is legal (every non-empty read truncates to the marker +
|
||
# notice) — it is not a kill switch, so no lower-bound error.
|
||
monkeypatch.setenv("BOR_READ_MAX_CHARS", "0")
|
||
assert _settings().read_max_chars == 0
|
||
|
||
|
||
def test_read_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""A negative cap is a typo — it would slice from the END of the
|
||
content (negative indexing) instead of failing, so the validator
|
||
fails loudly at startup (the ``agent_max_rounds`` pattern)."""
|
||
monkeypatch.setenv("BOR_READ_MAX_CHARS", "-1")
|
||
with pytest.raises(ValidationError, match="read_max_chars"):
|
||
_settings()
|
||
|
||
|
||
def test_recency_boost_default_and_env_override(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Phase 106, D6: the recency boost on the RRF-fused score is ON by
|
||
default (0.0007 — the fine-line-tuned value, task 07; ``0`` is the
|
||
byte-identical kill switch) with a 365-day decay timescale; both
|
||
env-tunable so the owner re-tunes live."""
|
||
monkeypatch.delenv("BOR_RECENCY_BOOST", raising=False)
|
||
monkeypatch.delenv("BOR_RECENCY_HALF_LIFE_DAYS", raising=False)
|
||
s = _settings()
|
||
assert s.recency_boost == 0.0007
|
||
assert s.recency_half_life_days == 365
|
||
monkeypatch.setenv("BOR_RECENCY_BOOST", "0")
|
||
monkeypatch.setenv("BOR_RECENCY_HALF_LIFE_DAYS", "90")
|
||
s = _settings()
|
||
assert s.recency_boost == 0.0
|
||
assert s.recency_half_life_days == 90
|
||
monkeypatch.setenv("BOR_RECENCY_BOOST", "0.002")
|
||
assert _settings().recency_boost == 0.002
|
||
|
||
|
||
def test_recency_boost_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""``0`` is the kill switch — a NEGATIVE boost would demote fresh
|
||
documents (the exact opposite of D6), so the validator fails loudly
|
||
at startup naming the field (the ``agent_max_rounds`` pattern)."""
|
||
monkeypatch.setenv("BOR_RECENCY_BOOST", "-0.001")
|
||
with pytest.raises(ValidationError, match="recency_boost"):
|
||
_settings()
|
||
|
||
|
||
def test_recency_half_life_rejects_non_positive(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""A ``0``/negative decay timescale would divide the exponent by
|
||
zero — the validator fails loudly at startup naming the field."""
|
||
for bad in ("0", "-365"):
|
||
monkeypatch.setenv("BOR_RECENCY_HALF_LIFE_DAYS", bad)
|
||
with pytest.raises(ValidationError, match="recency_half_life_days"):
|
||
_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).
|
||
Phase 91 (task 03): the retired CSS-file theme env var is gone —
|
||
``Settings`` no longer has a theme field at all (a leftover value
|
||
in a deployment's .env is ignored, not a boot failure)."""
|
||
s = _settings()
|
||
assert s.input_placeholder == "Ask me anything…"
|
||
assert s.footer_text == "Powered by self-hosted models"
|
||
assert "theme" not in type(s).model_fields
|
||
|
||
|
||
def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""The two string settings honor their ``BOR_`` env vars
|
||
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``);
|
||
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")
|
||
s = _settings()
|
||
assert s.input_placeholder == "Ask the vault…"
|
||
assert s.footer_text == "Powered by my own models"
|
||
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "")
|
||
assert _settings().input_placeholder == "" # empty stands
|