"""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)