Files
brain-of-reese/tests/unit/test_settings_live_apply.py
T
ducoterra 1200437bf8
Build and Push Containers / build-and-push-app (push) Successful in 4m4s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 168_admin_settings_panel
**Phase 168 — Admin Settings panel — final verification: PASS**

1. **Implemented/verified:** `/settings.html` (admin-only) with the 75-field catalogue (`app/core/settings_catalog.py`), DB-over-env overrides in Postgres (`app_settings`, migration 0028 at head, `settings_overlay.py` resolver, `app/api/admin_settings.py` — 4 admin-only routes), live-apply on save with no restart + restart-required badges, Reset/Clear all, write-only secrets, `.env`-never-written, `BOR_SETTINGS_DB=0` kill switch.
2. **Phase E2E: 12/12** — run with `E2E_APP_PORT=8200` (ports 8123/8124 are held by an unrelated "undercooked" project; no process killed, no code change — the documented env override).
3. **Regression set: all 7 suites green in isolation** (theme tab, API tokens, configurable brand, global tuning, admin auth, cache busting, wide desktop column — 38 tests).
4. **Full suite: unit+integration exit 0 (4066 passed), app/ coverage 99%** (gate >90%); **ruff clean, pyright 0 errors / 0 warnings.**
5. **Log contract (rule 10) verified live:** per-turn + per-response byte-budget lines present; settings write lines carry key + action, **never the value**.
6. **Byte-budget audit: all endpoints under the 262,144 B budget**; settings catalogue 23,513 B (gzip 3,326).
7. **Defect fixed (one):** criterion 5 had no integration pin — added `test_bad_row_inserted_directly_never_bricks_boot` to `tests/integration/test_admin_settings_api.py` (malformed row inserted directly → fresh `create_app()` boots, row skipped with logged reason, rest of table applies, `/api/health` 200) — 12/12 green.
8. **Docs pass:** `README.md` and `AGENTS.md` reviewed in full — both consistent with the as-built feature (Settings page table, Settings section, Configuration-reference note, `.env.example` block); **no changes required**.
9. **Cleanup:** audit app killed (by PID); `app_settings` left empty (0 rows); all changes left in the working tree (no commit, per protocol).
10. **Completion criteria:** all 7 checkboxes verified green (criterion 7's commit is the harness's).
11. **Next pending phase:** `170_edit_bubble_sizing`.
2026-10-10 01:05:14 -04:00

189 lines
7.2 KiB
Python

"""Phase 168 (task 03): Save re-points the two in-process singletons.
The "takes effect on Save" contract (D3): ``invalidate_settings()``
clears the ``get_settings()`` cache (the ~70 per-use settings) AND runs
the registered reset hooks — ``app.api.chat.reset_runtime_caches``
(nulls the shared ``LLMClient`` and the chat concurrency semaphore,
re-registered by ``create_app()`` at the composition root because
``app/core`` must never import ``app/api``, PLAN §3). These pins are
unit level: the chat globals are set directly, the overlay hooks are
registered directly (the autouse fixture gives every test a clean hook
list and restores every global it touches — a leaked ``LLMClient``
holding a test settings snapshot would poison the next suite's
singleton).
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from pathlib import Path
import pytest
import app.main as app_main
from app.api import chat as chat_api
from app.config import get_settings, invalidate_settings
from app.core import settings_overlay
@pytest.fixture(autouse=True)
def _clean_state() -> Iterator[None]:
"""Clean hook list + saved chat globals, restored on teardown.
``reset_state_for_tests()`` clears the process-wide hook list (a
prior suite's test may have wiped the hook ``app.main`` registered
at import — these tests register their own); the ``chat_api``
globals are saved/restored around the test so a built ``LLMClient``
or semaphore never leaks into another test's singleton slot.
"""
settings_overlay.reset_state_for_tests()
saved = {
"_llm": chat_api._llm,
"_chat_semaphore": chat_api._chat_semaphore,
"_chat_active": chat_api._chat_active,
}
yield
chat_api._llm = saved["_llm"]
chat_api._chat_semaphore = saved["_chat_semaphore"]
chat_api._chat_active = saved["_chat_active"]
settings_overlay.reset_state_for_tests()
# monkeypatch restores BOR_* env first (finalized before this
# fixture) — the cache clear re-resolves to the conftest-pinned
# env-only view.
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# reset_runtime_caches — nulls exactly the two settings snapshots
# ---------------------------------------------------------------------------
def test_reset_nulls_llm_and_semaphore() -> None:
chat_api._llm = object()
chat_api._chat_semaphore = object()
chat_api._chat_active = 7
chat_api.reset_runtime_caches()
assert chat_api._llm is None
assert chat_api._chat_semaphore is None
# _chat_active counts live turns — a reset must not touch it (the
# new chat_max_concurrent applies as permits are taken).
assert chat_api._chat_active == 7
def test_reset_is_idempotent() -> None:
chat_api.reset_runtime_caches()
chat_api.reset_runtime_caches() # a double Save must not raise
assert chat_api._llm is None
assert chat_api._chat_semaphore is None
# ---------------------------------------------------------------------------
# get_llm / _get_chat_semaphore — lazy rebuild from the CURRENT settings
# ---------------------------------------------------------------------------
def test_get_llm_singleton_contract() -> None:
chat_api._llm = None
first = chat_api.get_llm()
second = chat_api.get_llm()
# The singleton contract still holds — the same instance until a
# reset (the pin that stops an "always construct" shortcut that
# would break connection pooling).
assert first is second
def test_get_llm_rebuilds_after_reset() -> None:
chat_api._llm = None
first = chat_api.get_llm()
chat_api.reset_runtime_caches()
second = chat_api.get_llm()
assert second is not first
assert second.settings is get_settings()
def test_get_chat_semaphore_rebuilds_after_reset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
chat_api._chat_semaphore = None
base = chat_api._get_chat_semaphore()
base_value = base._value # pyright: ignore[reportPrivateUsage]
# Drive the cap to a value different from the current one (the
# env is the base — the kill switch is off, conftest pin), so the
# rebuild's new _value is unambiguous.
target = 3 if base_value != 3 else 5
monkeypatch.setenv("BOR_CHAT_MAX_CONCURRENT", str(target))
get_settings.cache_clear()
chat_api.reset_runtime_caches()
rebuilt = chat_api._get_chat_semaphore()
assert rebuilt is not base
assert rebuilt._value == target # pyright: ignore[reportPrivateUsage]
# restore: the env reverts via monkeypatch, and the fixture's cache
# clear re-resolves the settings.
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# the create_app() wiring — the composition root registers the hook
# ---------------------------------------------------------------------------
def test_create_app_wires_the_reset_hook() -> None:
"""``register_invalidator(reset_runtime_caches)`` inside
``create_app`` — the static pin that stops the wiring being
"cleaned up" (without it a Save silently stops retuning the LLM
client)."""
src = Path(app_main.__file__).read_text()
start = src.index("def create_app(")
end = src.index("\napp = create_app()", start)
body = src[start:end]
assert "register_invalidator(reset_runtime_caches)" in body
def test_app_core_imports_no_app_api() -> None:
"""Dependency direction (PLAN §3): ``app/core/`` never imports an
``app.api`` symbol — the hook exists so the composition root can
wire the reset without inverting it."""
core = Path(app_main.__file__).resolve().parent / "core"
for py in sorted(core.glob("*.py")):
src = py.read_text()
for line in src.splitlines():
stripped = line.strip()
assert not (
stripped.startswith("from app.api")
or stripped.startswith("import app.api")
), f"{py.name} imports app.api (PLAN §3 direction)"
# ---------------------------------------------------------------------------
# invalidate_settings — cache clear + hook run, a failed reset stands
# ---------------------------------------------------------------------------
def test_invalidate_runs_hooks_and_re_resolves(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
settings_overlay.register_invalidator(lambda: calls.append("hook"))
get_settings.cache_clear()
get_settings() # a cached hit under the ORIGINAL env
monkeypatch.setenv("BOR_APP_NAME", "Live-Apply-Check")
invalidate_settings() # clears the cache + runs the hooks
assert calls == ["hook"]
# the re-resolution sees the NEW env — the cache really was cleared
assert get_settings().app_name == "Live-Apply-Check"
def test_invalidate_survives_a_raising_hook(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A failed reset must not turn a committed save into a 500 — the
exception is logged, never propagated."""
def _boom() -> None:
raise RuntimeError("boom")
settings_overlay.register_invalidator(_boom)
with caplog.at_level(logging.ERROR, logger="app.settings"):
invalidate_settings() # must not raise
assert any("settings invalidator failed" in r.getMessage() for r in caplog.records)