"""Unit tests: ``scripts/regen_folder_summaries.py`` wiring (phase 155, task 02). The one-shot full-regeneration entrypoint is pinned by wiring, not by a live LLM or DB: a fake ``LLMClient``, a fake short-lived session, and a stubbed ``generate_folder_summaries`` that records its call. The pins: * the FULL-mode call contract — the stub is called with NO kwargs (``changed_docs=None`` / ``only_missing=False`` / ``skip=False`` are the generator's defaults; the phase-127 change-gated sync path is untouched, the script is a consumer of the full mode); * the stats dict surfaces on the single cron-safe summary line (``regen_folder_summaries: folders=… failed=… pruned=… kept_manual=… limit=…``) on the module logger; * the ``--limit`` debug passthrough (parity with ``import_docs``'s parser) is named on the summary line without disturbing the generator's call contract; * the settings/boot failure exits non-zero with the NAMED stderr line — the app validator idiom (``ensure_admin_configured`` names the missing ``BOR_`` variable; a settings validation error names the field); the LLM/session/generator never run on a boot failure; * a DB/commit outage exits non-zero with the named stderr line (never a silent partial — the session still closes, no summary line); * ``main()`` calls ``configure_debugging()`` (the phase-8 idiom — the ``DEBUGPY=1`` listener contract itself is the phase-8 precedent, ``tests/unit/test_debugging.py``). """ from __future__ import annotations import logging import pytest from app.config import Settings from scripts import regen_folder_summaries as regen STATS = {"generated": 3, "failed": 1, "pruned": 2, "kept_manual": 1} class _FakeLLM: """A duck-typed ``LLMClient`` stand-in (records construction).""" class _FakeSession: """A short-lived session stub (phase-53 convention: the script's own session commits; the generator only flushes).""" def __init__(self, commit_error: Exception | None = None) -> None: self.commit_error = commit_error self.committed = False self.closed = False def commit(self) -> None: if self.commit_error is not None: raise self.commit_error self.committed = True def close(self) -> None: self.closed = True class _GenerateStub: """The stubbed ``generate_folder_summaries``: records the call's positional args and kwargs and returns the fixed stats dict.""" def __init__(self) -> None: self.calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] self.raised: Exception | None = None async def __call__( self, session: object, llm: object, **kwargs: object ) -> dict[str, int]: self.calls.append(((session, llm), dict(kwargs))) if self.raised is not None: raise self.raised return dict(STATS) def _wire( monkeypatch: pytest.MonkeyPatch, ) -> tuple[_FakeSession, _GenerateStub, list[logging.LogRecord]]: """Boot the wiring with a fake LLM, fake session, and generator stub; ``configure_logging``/``configure_debugging`` are spied (the phase-8 idiom is pinned by the call, the listener contract by the phase-8 precedent file).""" session = _FakeSession() stub = _GenerateStub() llm = _FakeLLM() monkeypatch.setattr( regen, "get_settings", lambda: Settings(_env_file=None), # pyright: ignore[reportCallIssue] ) monkeypatch.setattr(regen, "configure_logging", lambda level: None) debugging_calls: list[int] = [] monkeypatch.setattr( regen, "configure_debugging", lambda: debugging_calls.append(1) or False, ) monkeypatch.setattr(regen, "LLMClient", lambda: llm) monkeypatch.setattr(regen, "SessionLocal", lambda: session) monkeypatch.setattr(regen, "generate_folder_summaries", stub) records: list[logging.LogRecord] = [] class _Sink(logging.Handler): def emit(self, record: logging.LogRecord) -> None: records.append(record) sink = _Sink() regen_logger = logging.getLogger("scripts.regen_folder_summaries") regen_logger.addHandler(sink) regen_logger.setLevel(logging.INFO) return session, stub, records def _summary_lines(records: list[logging.LogRecord]) -> list[str]: return [r.getMessage() for r in records if r.getMessage().startswith( "regen_folder_summaries:" )] # ---------- the parser (the import_docs parity flag) ---------- def test_parser_defaults_to_no_limit() -> None: args = regen.build_parser().parse_args([]) assert args.limit is None def test_parser_limit_parity_flag() -> None: args = regen.build_parser().parse_args(["--limit", "5"]) assert args.limit == 5 # ---------- the success path: the FULL-mode call contract ---------- def test_main_full_mode_defaults_and_stats_on_summary_line( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: session, stub, records = _wire(monkeypatch) rc = regen.main([]) out = capsys.readouterr() assert rc == 0 assert out.err == "" # success: no stderr noise # The FULL-mode call contract: exactly one call, (session, llm) # positional, NO kwargs — changed_docs=None / only_missing=False / # skip=False are the generator's defaults (the phase-127 change- # gated sync path is untouched — the script is a consumer of the # default mode, not of changed_docs). assert len(stub.calls) == 1 (pos, kwargs), = stub.calls assert kwargs == {} assert isinstance(pos[0], _FakeSession) # the short-lived session assert isinstance(pos[1], _FakeLLM) # the LLMClient stand-in # The phase-53 convention: the script's own session commits + closes. assert session.committed is True assert session.closed is True # The stats dict surfaces on the single cron-safe summary line. lines = _summary_lines(records) assert len(lines) == 1 assert lines[0] == ( "regen_folder_summaries: folders=3 failed=1 pruned=2 " "kept_manual=1 limit=none" ) def test_main_debug_parsing_runs_full_mode_and_names_limit( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """The --limit debug passthrough (parity with import_docs): named on the summary line WITHOUT disturbing the generator's call contract — the full-mode defaults are still the no-kwargs call.""" _session, stub, records = _wire(monkeypatch) rc = regen.main(["--limit", "7"]) assert rc == 0 assert len(stub.calls) == 1 assert stub.calls[0][1] == {} # the full-mode defaults, untouched assert _summary_lines(records) == [ "regen_folder_summaries: folders=3 failed=1 pruned=2 " "kept_manual=1 limit=7" ] def test_main_calls_configure_debugging( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """The phase-8 wiring pin: main() calls configure_debugging() (DEBUGPY=1 honored, default off — the listener contract itself is the phase-8 precedent, tests/unit/test_debugging.py).""" called: list[int] = [] session, _stub, _records = _wire(monkeypatch) monkeypatch.setattr( regen, "configure_debugging", lambda: called.append(1) or False ) assert regen.main([]) == 0 assert called == [1] assert session.committed is True # ---------- the fail-loud boot (the app validator idiom) ---------- def test_main_boot_failure_missing_admin_secret_is_named( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """A missing BOR_ADMIN_PASSWORD: the app's fail-loud gate (ensure_admin_configured) names the variable, main exits non-zero with the named stderr line, and the LLM/session/generator NEVER run (never a silent partial).""" monkeypatch.setattr( regen, "get_settings", lambda: Settings(_env_file=None, admin_password=""), # pyright: ignore[reportCallIssue] ) monkeypatch.setattr(regen, "configure_logging", lambda level: None) monkeypatch.setattr(regen, "configure_debugging", lambda: False) llm_calls: list[int] = [] monkeypatch.setattr( regen, "LLMClient", lambda: llm_calls.append(1) or _FakeLLM() ) session = _FakeSession() monkeypatch.setattr(regen, "SessionLocal", lambda: session) stub = _GenerateStub() monkeypatch.setattr(regen, "generate_folder_summaries", stub) rc = regen.main([]) err = capsys.readouterr().err assert rc == 1 assert "regen_folder_summaries: boot failed:" in err assert "BOR_ADMIN_PASSWORD" in err # the gate names the variable assert llm_calls == [] # the LLM never constructed assert stub.calls == [] # the generator never ran assert session.committed is False and session.closed is False def test_main_settings_validation_error_is_named( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """A settings validation error (a bad env value — the settings validator naming the offending field) names the failure the same way: non-zero exit, the named line, nothing runs.""" def _bad_settings() -> Settings: # The Settings validator rejects a negative recency_boost — # the pydantic ValidationError names the field. return Settings(_env_file=None, recency_boost=-1) # pyright: ignore[reportCallIssue] monkeypatch.setattr(regen, "get_settings", _bad_settings) monkeypatch.setattr(regen, "configure_logging", lambda level: None) llm_calls: list[int] = [] monkeypatch.setattr( regen, "LLMClient", lambda: llm_calls.append(1) or _FakeLLM() ) stub = _GenerateStub() monkeypatch.setattr(regen, "generate_folder_summaries", stub) rc = regen.main([]) err = capsys.readouterr().err assert rc == 1 assert "regen_folder_summaries: boot failed:" in err assert "recency_boost" in err assert llm_calls == [] assert stub.calls == [] # ---------- the fail-loud run (never a silent partial) ---------- def test_main_commit_outage_fails_loud( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """A DB/commit outage: the failure is NAMED on stderr, the exit code is non-zero, the session still closes (the uncommitted regeneration rolls back with it — never a silent partial), and NO summary line is logged (a failed run reports nothing generated).""" session, stub, records = _wire(monkeypatch) session.commit_error = RuntimeError("db down (test sentinel)") rc = regen.main([]) err = capsys.readouterr().err assert rc == 1 assert "regen_folder_summaries: regeneration failed:" in err assert "db down (test sentinel)" in err assert len(stub.calls) == 1 # the generator ran… assert session.committed is False # …but the commit failed assert session.closed is True # …and the session closed (rollback) assert _summary_lines(records) == [] # no success line assert "regen_folder_summaries: folders=" not in err