**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`.
189 lines
7.2 KiB
Python
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)
|