Files
brain-of-reese/tests/integration/test_sync_folder_summaries.py
T
ducoterra d4943b4822
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 25s
phase: 94_ls_tree_drilldown
All green. Verification complete.

**Phase 94 — `ls` drill-down tree: final verification pass (all 5 tasks were already complete; verified, nothing to fix)**

- Verified `ls` 3-level tree (`app/rag/agent.py`): `ls()` sources + summaries, `ls(source)`/`ls(source/folder)` drill-down, 50-line file cap + grep-pointer note, NOT-A-FOLDER teaching refusal
- Verified `folder_summaries` (migration 0017, model, `app/rag/folder_summaries.py` generator: `FOLDER_SUMMARY_MODE` marker, fail-soft per folder, ≥2-doc scope + prune) wired change-gated in both sync paths
- Verified 10-turn fixture battery verdict recorded in `TOOL_CALLING_TESTING.md` §9 (2026-09-11): turbo PASS 19/19 contract, 98.7 s (−12.5…−13.2 % vs baseline); lite PASS 18/18, 43.6 s (+7.7 %) — accuracy at/above baseline, gate met
- `uv run pytest --cov=app --cov-report=term-missing` → 1939 passed, 0 failed; TOTAL coverage **99 %** (folder_summaries.py 100 %)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- E2E in isolation: `test_ls_tree_drilldown.py` 3 passed; `test_agent_document_tools` 4, `test_agent_unlimited_tools` 4, `test_harness_aligned_tools` 3, `test_search_tool` 3, `test_grep_regex_teaching` 2, `test_response_to_docs` 4 — all passed (read/grep contracts untouched)
- Dedicated folder-summary tests (fail-soft, prune, both sync paths, migration): 46 passed
- Completion criteria: all 6 met; working tree holds only phase-94 changes (commit left to harness per protocol)

**Next pending phase:** `95_read_truncation_cap`
2026-09-11 00:59:35 -04:00

665 lines
25 KiB
Python

"""Integration: the sync-time folder-summary wiring (phase 94, task 02).
Extends the KB-overview sync pattern (``test_import_docs_overview.py``
is the template) to the phase-94 folder summaries — both sync paths
regenerate ``folder_summaries`` change-gated (added + updated > 0) or
on an empty table (the first full run after migration 0017 / a
``--limit`` first walk), per-folder fail-soft, best-effort, and in the
run's own short-lived session (the phase-53 flush-then-caller-commits
convention — the generator only flushes, the sync path commits).
Script path (``scripts.import_docs.main`` end to end, fake LLM, real
DB, explicit ``--source``):
- a KB-changing import → one row per ≥ 2-doc subtree (the source root
+ the 2-doc folder; the 1-doc folder gets none), committed in the
run's transaction, the summary line ending
``folder_summaries=<generated>/<failed>/<pruned>``;
- an unchanged re-import → zero ``lite`` calls,
``folder_summaries=skipped``, rows untouched;
- a subtree dropping below 2 docs after a changed re-walk → its row
pruned;
- one folder's ``lite`` failure → its previous row kept, the other
lands, exit code 0, stats ``1/1/0``;
- a ``--limit`` debug run → no generation, no rows,
``folder_summaries=skipped``;
- a fresh (empty) table after a ``--limit`` first walk → an unchanged
full walk generates (the table-empty first-run trigger).
API path (``POST /api/sync`` end to end, real import over a host temp
local dir, deterministic ``FakeEmbedder``):
- a KB-changing sync → the rows land (visible via the test's own
session) and the status detail keeps its exact pre-phase key set
(no folder-summary surface — the stats are log-only);
- an unchanged re-sync → zero ``FOLDER_SUMMARY_MODE`` calls;
- a ``lite`` outage (one folder failing) → the failed folder's row is
kept, the run reports ``success`` (never ``failed``), and the
sources-version bump still lands (the bump is change-gated on the
KB, not on the summaries);
- an empty table after a populated sync (the migration-0017 scenario)
→ an unchanged walk regenerates (the overview's API gate, purely
change-gated, does not).
"""
from __future__ import annotations
import logging
import time
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.api import sync as sync_api
from app.config import Settings
from app.db import SessionLocal, db_available
from app.main import app as fastapi_app
from app.models import FolderSummary, GitSource
from app.rag import git_sources as git_sources_resolver
from app.rag.llm import LLMError
from app.rag.sources_meta import current_sources_version
from scripts import import_docs
from tests.conftest import ADMIN_PASSWORD
from tests.fakes import FakeEmbedder
# --- shared helpers ---------------------------------------------------------
def _folder_calls(llm: FakeEmbedder) -> list[list[dict[str, str]]]:
"""The ``FOLDER_SUMMARY_MODE`` chat calls on the client (the marker
in the system prompt — the KB-overview marker never contains it)."""
return [
msgs
for msgs in llm.chat_calls
if any(
"FOLDER_SUMMARY_MODE" in m.get("content", "")
for m in msgs
if m.get("role") == "system"
)
]
def _rows(db: Session) -> dict[tuple[str, str], str]:
"""The stored folder summaries as ``{(source, folder_path): summary}``."""
db.expire_all()
return {
(source, folder_path): summary
for source, folder_path, summary in db.execute(
select(FolderSummary.source, FolderSummary.folder_path, FolderSummary.summary)
).all()
}
def _updated_at(db: Session, source: str, folder_path: str) -> datetime | None:
db.expire_all()
row = db.get(FolderSummary, (source, folder_path))
return row.updated_at if row is not None else None
class FolderFailingChatEmbedder(FakeEmbedder):
"""``lite`` stand-in that fails exactly one folder-summary call —
the one whose user message contains *fail_label* (a ``Folder: …``
header) — and answers everything else (incl. the KB overview)
normally. Drives the per-folder fail-soft path."""
def __init__(self, fail_label: str) -> None:
super().__init__()
self.fail_label = fail_label
async def chat(
self, messages: list[dict[str, str]], model: str | None = None
) -> str:
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if self.fail_label in user:
self.chat_calls.append(list(messages))
raise LLMError("simulated folder-summary outage (test sentinel)")
return await super().chat(messages, model)
# --- fixtures ----------------------------------------------------------------
@pytest.fixture(autouse=True)
def _clean_kb(db: Session) -> Iterator[None]:
"""Global KB + registry state, truncated around every test (the
real import and the real generator write ``documents``/``chunks``/
``folder_summaries``; the API tests seed ``git_sources``)."""
db.execute(
text("TRUNCATE chunks, documents, kb_overview, folder_summaries, git_sources")
)
db.commit()
yield
db.execute(
text("TRUNCATE chunks, documents, kb_overview, folder_summaries, git_sources")
)
db.commit()
@pytest.fixture(autouse=True)
def _reset_sources_version() -> Iterator[None]:
"""The sources version counter is global mutable state — pin it to
the migration-0010 seed (0) around every test (own session: both
sync paths bump through their own short-lived ``SessionLocal``).
Skips like the ``db`` fixture when Postgres is down."""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
session = SessionLocal()
try:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
yield
finally:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
session.close()
@pytest.fixture(autouse=True)
def _fresh_sync_state() -> Iterator[None]:
"""The module-level status object + task are process-global: reset
them around every test (harmless for the script-path tests)."""
sync_api._status = sync_api.SyncStatus()
sync_api._task = None
yield
sync_api._status = sync_api.SyncStatus()
sync_api._task = None
@pytest.fixture()
def src(tmp_path: Path) -> Path:
"""MyDocs: a/ (2 docs) + b/ (1 doc) → subtree counts root 3, a 2, b 1.
md → no document-summary chat calls, so the ``lite`` traffic is
exactly the overview + the folder summaries."""
root = tmp_path / "MyDocs"
(root / "a").mkdir(parents=True)
(root / "b").mkdir()
(root / "a" / "one.md").write_text("# A One\nFirst folder document.\n", encoding="utf-8")
(root / "a" / "two.md").write_text("# A Two\nSecond folder document.\n", encoding="utf-8")
(root / "b" / "three.md").write_text("# B Three\nLone folder document.\n", encoding="utf-8")
return root
def _run_main(
monkeypatch: pytest.MonkeyPatch,
llm: FakeEmbedder,
argv: list[str],
capsys: pytest.CaptureFixture[str],
) -> tuple[int, str]:
"""Run ``import_docs.main`` with fresh settings, a fake LLM, and a
fail-loud git mock (``--source`` always wins, so git must stay
idle) — the phase-31 overview test's runner."""
monkeypatch.setattr(
import_docs,
"get_settings",
lambda: Settings(_env_file=None), # pyright: ignore[reportCallIssue]
)
def _no_git(url: str, dest: Path | str) -> Path:
raise AssertionError("git sync must not run with explicit --source")
monkeypatch.setattr(import_docs, "clone_or_pull", _no_git)
monkeypatch.setattr(import_docs, "LLMClient", lambda: llm)
rc = import_docs.main(argv)
return rc, capsys.readouterr().out
# --- script path: scripts/import_docs.py -------------------------------------
def test_changed_import_generates_folder_rows(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A KB-changing import upserts one row per ≥ 2-doc subtree in the
run's own transaction — committed and visible afterwards — with the
stats on the summary line (PLAN §9)."""
llm = FakeEmbedder()
records: list[logging.LogRecord] = []
class _Sink(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
fs_logger = logging.getLogger("app.rag.folder_summaries")
sink = _Sink()
fs_logger.addHandler(sink)
fs_logger.setLevel(logging.INFO)
try:
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
finally:
fs_logger.removeHandler(sink)
assert rc == 0
assert "added=3" in out
assert out.rstrip().endswith(
"overview=updated sources_version=1 folder_summaries=2/0/0"
)
# Two FOLDER_SUMMARY_MODE calls — the source root (3 docs) and the
# 2-doc folder a; the 1-doc folder b gets no row (no lite burn).
calls = _folder_calls(llm)
assert len(calls) == 2
headers = [c[1]["content"].splitlines()[0] for c in calls]
assert headers == ["Folder: MyDocs", "Folder: MyDocs/a"]
for c in calls:
assert "FOLDER_SUMMARY_MODE" in c[0]["content"]
# The rows land committed (the run's own session committed them) —
# one per ≥ 2-doc subtree, never empty, stamped.
rows = _rows(db)
assert set(rows) == {("MyDocs", ""), ("MyDocs", "a")}
assert all(rows.values())
assert _updated_at(db, "MyDocs", "a") is not None
# The stats log line (PLAN §9 ample logging).
assert any(
"folder_summaries: generated=2 failed=0 pruned=0" in r.getMessage()
for r in records
)
def test_unchanged_reimport_burns_zero_folder_calls(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Same hashes → no KB change → the populated table stays untouched
and zero ``lite`` calls burn (overview AND folder summaries)."""
llm1 = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm1, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith(
"overview=updated sources_version=1 folder_summaries=2/0/0"
)
rows = _rows(db)
assert set(rows) == {("MyDocs", ""), ("MyDocs", "a")}
llm2 = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm2, ["--source", str(src)], capsys)
assert rc == 0
assert "unchanged=3" in out
assert out.rstrip().endswith(
"overview=skipped sources_version=skipped folder_summaries=skipped"
)
assert llm2.chat_calls == [] # zero lite calls, any mode
assert _rows(db) == rows # rows byte-identical
def test_subtree_dropping_below_two_docs_is_pruned(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A subtree that drops below the ≥ 2 rule on a changed re-walk
loses its (stale) row; the remaining qualifiers regenerate."""
llm1 = FakeEmbedder()
rc, _ = _run_main(monkeypatch, llm1, ["--source", str(src)], capsys)
assert rc == 0
assert set(_rows(db)) == {("MyDocs", ""), ("MyDocs", "a")}
# a/ loses one of its two docs (below the ≥ 2 rule) while b's doc
# changes → the re-walk is a KB change, so generation runs and the
# stale a/ row is pruned; b (1 doc) still gets no row.
(src / "a" / "two.md").unlink()
(src / "b" / "three.md").write_text("# B Three\nChanged content.\n", encoding="utf-8")
llm2 = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm2, ["--source", str(src), "--prune"], capsys)
assert rc == 0
assert "pruned=1" in out
assert "updated=1" in out
assert out.rstrip().endswith(
"overview=updated sources_version=2 folder_summaries=1/0/1"
)
# Only the source root (the 2 remaining docs) still qualifies.
assert set(_rows(db)) == {("MyDocs", "")}
assert _updated_at(db, "MyDocs", "a") is None # the row is gone
def test_folder_lite_failure_keeps_previous_row_and_stays_green(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""One folder's ``lite`` failure: its previous row is kept, the
other folder lands, and the run's exit code stays 0 — the stats
carry the failure (``1/1/0``)."""
llm1 = FakeEmbedder()
rc, _ = _run_main(monkeypatch, llm1, ["--source", str(src)], capsys)
assert rc == 0
rows_before = _rows(db)
a_stamp_before = _updated_at(db, "MyDocs", "a")
root_stamp_before = _updated_at(db, "MyDocs", "")
assert a_stamp_before is not None and root_stamp_before is not None
# a/ changes (a KB change → generation runs); lite fails for a/
# only — its previous row must stay, the root row must land.
(src / "a" / "one.md").write_text("# A One\nChanged content.\n", encoding="utf-8")
llm2 = FolderFailingChatEmbedder(fail_label="Folder: MyDocs/a")
rc, out = _run_main(monkeypatch, llm2, ["--source", str(src)], capsys)
assert rc == 0 # a failed folder must not fail the import
assert "updated=1" in out
assert out.rstrip().endswith(
"overview=updated sources_version=2 folder_summaries=1/1/0"
)
rows_after = _rows(db)
assert rows_after["MyDocs", "a"] == rows_before["MyDocs", "a"] # kept
assert _updated_at(db, "MyDocs", "a") == a_stamp_before # untouched
root_stamp_after = _updated_at(db, "MyDocs", "")
assert root_stamp_after is not None and root_stamp_after > root_stamp_before
def test_limit_run_skips_folder_generation(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A ``--limit`` debug run (an incomplete walk) never generates —
no ``lite`` call at all, no rows, the line says ``skipped``."""
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--limit", "2"], capsys)
assert rc == 0
assert "added=2" in out
assert out.rstrip().endswith(
"overview=skipped sources_version=skipped folder_summaries=skipped"
)
assert llm.chat_calls == [] # no lite call, any mode
assert _rows(db) == {} # an incomplete walk never writes rows
def test_empty_table_generates_on_unchanged_walk(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""The table-empty first-run trigger: after a ``--limit`` first
walk (populated KB, empty table), an unchanged full walk generates
— for the folder summaries AND the missing outline, still never
bumping the version."""
llm1 = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm1, ["--source", str(src), "--limit", "3"], capsys)
assert rc == 0
assert "added=3" in out
assert out.rstrip().endswith(
"overview=skipped sources_version=skipped folder_summaries=skipped"
)
assert _rows(db) == {}
llm2 = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm2, ["--source", str(src)], capsys)
assert rc == 0
assert "unchanged=3" in out
assert out.rstrip().endswith(
"overview=updated sources_version=skipped folder_summaries=2/0/0"
)
assert set(_rows(db)) == {("MyDocs", ""), ("MyDocs", "a")}
assert len(_folder_calls(llm2)) == 2
assert current_sources_version(db) == 0 # the unchanged walk never bumps
# --- API path: POST /api/sync -------------------------------------------------
@pytest.fixture()
def sync_client() -> Iterator[TestClient]:
"""Context-managed TestClient — one app event loop across requests
(the background task must survive between the POST and the polls)."""
with TestClient(fastapi_app) as client:
yield client
def _settings(sources_dir: str) -> Settings:
return Settings(_env_file=None, sources_dir=sources_dir) # pyright: ignore[reportCallIssue]
def _stub_env(monkeypatch: pytest.MonkeyPatch, git_sources: str = "") -> None:
"""The resolver's env fallback, driven by a fresh ``Settings`` (the
dev ``.env`` never leaks in)."""
monkeypatch.setattr(
git_sources_resolver,
"get_settings",
lambda: Settings(_env_file=None, git_sources=git_sources), # pyright: ignore[reportCallIssue]
)
def _seed_local(db: Session, path: Path) -> None:
"""A ``kind=local`` row as the phase-38 API stores it: the expanded
absolute path in both ``path`` and the NOT-NULL ``url`` column."""
db.add(GitSource(url=str(path), kind="local", path=str(path)))
db.commit()
def _capture_llm(monkeypatch: pytest.MonkeyPatch) -> list[FakeEmbedder]:
"""The pipeline's ``LLMClient`` becomes a recorded
``FakeEmbedder`` (real import + real generator, no network)."""
clients: list[FakeEmbedder] = []
def _factory() -> FakeEmbedder:
client = FakeEmbedder()
clients.append(client)
return client
monkeypatch.setattr(sync_api, "LLMClient", _factory)
return clients
def _login(client: TestClient) -> None:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
def _poll(client: TestClient, want: str, timeout: float = 10.0) -> dict:
"""Poll ``GET /api/sync/status`` until ``state == want`` (terminal).
Any state other than ``running`` before the deadline fails loudly —
an unexpected ``failed`` must never be masked by the wait."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
body = client.get("/api/sync/status").json()
if body["state"] == want:
return body
assert body["state"] == "running", (
f"unexpected state {body['state']!r} while waiting for {want!r}: {body}"
)
time.sleep(0.05)
raise AssertionError(f"sync did not reach {want!r} within {timeout}s")
@pytest.fixture()
def local_dir(tmp_path: Path) -> Path:
"""LocalDocs: a/ (2 docs) → subtree counts root 2, a 2 (one
qualifying source-root row + one folder row)."""
d = tmp_path / "LocalDocs"
(d / "a").mkdir(parents=True)
(d / "a" / "one.md").write_text("# A One\nfirst local file\n", encoding="utf-8")
(d / "a" / "two.md").write_text("# A Two\nsecond local file\n", encoding="utf-8")
return d
def test_api_changed_sync_generates_folder_rows(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
local_dir: Path,
) -> None:
"""A KB-changing sync upserts the rows (visible via the test's own
session) and keeps the status detail's exact pre-phase shape — the
folder stats are log-only, not a new status surface."""
_seed_local(db, local_dir)
_stub_env(monkeypatch)
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: _settings(str(local_dir.parent / "bor")),
)
clients = _capture_llm(monkeypatch)
_login(sync_client)
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "success")
assert body["error"] is None
# No new sync-status surface: the detail keeps its exact key set.
assert set(body["detail"]) == {
"files", "added", "updated", "unchanged", "pruned", "errors",
"chunks", "summaries", "summary_errors", "overview",
"sources_version",
}
assert body["detail"]["files"] == 2
assert body["detail"]["added"] == 2
assert body["detail"]["overview"] is True
assert body["detail"]["sources_version"] == 1
# One LLM client for probe + import + overview + folder summaries;
# exactly two FOLDER_SUMMARY_MODE calls (root + the 2-doc a/ folder).
assert len(clients) == 1
calls = _folder_calls(clients[0])
assert len(calls) == 2
headers = [c[1]["content"].splitlines()[0] for c in calls]
assert headers == ["Folder: LocalDocs", "Folder: LocalDocs/a"]
# The rows land committed — visible from the test's own session.
rows = _rows(db)
assert set(rows) == {("LocalDocs", ""), ("LocalDocs", "a")}
assert all(rows.values())
assert current_sources_version(db) == 1
def test_api_unchanged_resync_burns_zero_folder_calls(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
local_dir: Path,
) -> None:
"""Same files → unchanged re-sync: no ``lite`` call of any kind
(populated table), rows untouched, version unadvanced."""
_seed_local(db, local_dir)
_stub_env(monkeypatch)
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: _settings(str(local_dir.parent / "bor")),
)
clients = _capture_llm(monkeypatch)
_login(sync_client)
assert sync_client.post("/api/sync").status_code == 202
_poll(sync_client, "success")
rows = _rows(db)
assert set(rows) == {("LocalDocs", ""), ("LocalDocs", "a")}
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "success")
assert body["detail"]["overview"] is False
assert body["detail"]["sources_version"] == 1 # unchanged → no bump
assert len(clients) == 2
# Zero GENERATION calls — no folder summary, no KB overview. The
# only chat the re-sync's client makes is the phase-41 probe's ping.
assert _folder_calls(clients[1]) == []
assert all(
"KB_OVERVIEW_MODE" not in m.get("content", "")
for msgs in clients[1].chat_calls
for m in msgs
if m.get("role") == "system"
)
assert len(clients[1].chat_calls) == 1
assert clients[1].chat_calls[0][0]["content"] == "ping"
assert _rows(db) == rows
assert current_sources_version(db) == 1
def test_api_folder_lite_failure_keeps_rows_stays_green_and_bumps(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
local_dir: Path,
) -> None:
"""A ``lite`` outage for one folder: the run stays ``success``
(never ``failed``), the failed folder's previous row is kept, the
rest regenerates, and the sources-version bump still lands (the
bump is change-gated on the KB, not on the summaries)."""
_seed_local(db, local_dir)
_stub_env(monkeypatch)
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: _settings(str(local_dir.parent / "bor")),
)
clients = _capture_llm(monkeypatch)
_login(sync_client)
assert sync_client.post("/api/sync").status_code == 202
_poll(sync_client, "success")
rows_before = _rows(db)
a_stamp_before = _updated_at(db, "LocalDocs", "a")
root_stamp_before = _updated_at(db, "LocalDocs", "")
assert a_stamp_before is not None and root_stamp_before is not None
assert len(clients) == 1 # sync 1 used the recording factory
# a/ changes (the KB changes) while lite fails for a/ only.
(local_dir / "a" / "one.md").write_text(
"# A One\nchanged local file\n", encoding="utf-8"
)
monkeypatch.setattr(
sync_api,
"LLMClient",
lambda: FolderFailingChatEmbedder(fail_label="Folder: LocalDocs/a"),
)
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "success") # GREEN — a lite outage is fail-soft
assert body["error"] is None
assert body["detail"]["updated"] == 1
assert body["detail"]["sources_version"] == 2 # the bump was not blocked
assert current_sources_version(db) == 2
rows_after = _rows(db)
assert rows_after["LocalDocs", "a"] == rows_before["LocalDocs", "a"]
assert _updated_at(db, "LocalDocs", "a") == a_stamp_before # kept as-is
root_stamp_after = _updated_at(db, "LocalDocs", "")
assert root_stamp_after is not None and root_stamp_after > root_stamp_before
def test_api_empty_table_first_sync_regenerates(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
local_dir: Path,
) -> None:
"""The migration-0017 scenario: the KB predates the table — wipe
the rows and re-sync an unchanged KB: the empty-table trigger
fires for the folder summaries (the overview's API gate, purely
change-gated, does not)."""
_seed_local(db, local_dir)
_stub_env(monkeypatch)
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: _settings(str(local_dir.parent / "bor")),
)
clients = _capture_llm(monkeypatch)
_login(sync_client)
assert sync_client.post("/api/sync").status_code == 202
_poll(sync_client, "success")
assert set(_rows(db)) == {("LocalDocs", ""), ("LocalDocs", "a")}
db.execute(text("TRUNCATE folder_summaries"))
db.commit()
assert sync_client.post("/api/sync").status_code == 202
body = _poll(sync_client, "success")
assert body["detail"]["overview"] is False # unchanged → no overview
assert body["detail"]["sources_version"] == 1 # unchanged → no bump
assert len(clients) == 2
assert len(_folder_calls(clients[1])) == 2 # the folders regenerated
assert set(_rows(db)) == {("LocalDocs", ""), ("LocalDocs", "a")}