529 lines
20 KiB
Python
529 lines
20 KiB
Python
"""Integration: total source removal (phase 69, task 01).
|
|
|
|
Real Postgres (``podman compose up -d db``); the settings the router
|
|
reads are monkeypatched at fresh ``tmp_path`` dirs per test (the
|
|
``test_git_sources_upload.py`` ``_point_at`` pattern — the dev ``.env``
|
|
never leaks in, and no rmtree can ever aim at a real checkout or
|
|
upload), and the LLM is never hit: the endpoint's
|
|
``regenerate_overview`` becomes a counting spy (faked/spied per the
|
|
house pattern — the best-effort branch gets a spy that raises
|
|
``LLMError``).
|
|
|
|
Contract under test — ``DELETE /api/git-sources/{id}`` is a total
|
|
removal (owner request 2026-09-02), in the locked order:
|
|
|
|
* row + the source's documents (chunks + embeddings via the
|
|
``all, delete-orphan`` cascade) commit **first**; the app-managed
|
|
on-disk dir (git checkout / unpacked upload folder) is deleted
|
|
**after** the commit — absent dir is a no-op, foreign local dirs
|
|
(the owner's own) are never touched on disk;
|
|
* sibling guard — another stored row resolving to the same source name
|
|
(``https://e.com/r`` vs ``https://e.com/r.git``) keeps the shared
|
|
documents + files: only the row goes;
|
|
* ``sources_version`` bumps exactly once when documents were pruned
|
|
(the phase-53 saved-chat invalidation, same gate as sync) — no docs,
|
|
no bump;
|
|
* the overview refresh runs when pruned > 0 and is best-effort: a
|
|
failing spy still lands the 204 (and the bump — overview first, bump
|
|
second, mirroring ``_run_sync``);
|
|
* the 204 no-body contract and the 404/422 pins stay unchanged (the
|
|
404/422 pins live in ``test_git_sources_api.py``);
|
|
* one per-operation INFO line (PLAN §9) —
|
|
``source removed: kind=… name=… docs_pruned=… files_removed=yes|no|
|
|
skipped overview=… total_ms=…`` (``skipped`` for the sibling guard
|
|
and for foreign local dirs).
|
|
|
|
``git_sources`` / ``documents`` / ``chunks`` / ``kb_overview`` are
|
|
global state: reset around every test; the single-row ``sources_meta``
|
|
counter resets to 0 (the ``test_sync_api.py`` pattern).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api import git_sources as git_sources_api
|
|
from app.config import Settings
|
|
from app.models import Chunk, Document, GitSource
|
|
from app.rag.llm import LLMError
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_state(db: Session) -> Iterator[None]:
|
|
"""The registry + KB tables are global state: reset around every
|
|
test; the seeded ``sources_meta`` row resets to version 0."""
|
|
db.execute(text("TRUNCATE chunks, documents, kb_overview, git_sources"))
|
|
db.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
|
|
db.commit()
|
|
yield
|
|
db.execute(text("TRUNCATE chunks, documents, kb_overview, git_sources"))
|
|
db.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
|
|
db.commit()
|
|
|
|
|
|
class _OverviewSpy:
|
|
"""The endpoint's ``regenerate_overview`` seam: counts calls and
|
|
(optionally) raises — the LLM outage the best-effort branch must
|
|
swallow."""
|
|
|
|
def __init__(self, fail: BaseException | None = None) -> None:
|
|
self.calls = 0
|
|
self.fail = fail
|
|
|
|
async def __call__(self, llm: object, session: object = None) -> bool: # noqa: ARG002
|
|
self.calls += 1
|
|
if self.fail is not None:
|
|
raise self.fail
|
|
return True
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def overview_spy(monkeypatch: pytest.MonkeyPatch) -> _OverviewSpy:
|
|
"""The LLM is never hit: by default the spy succeeds (the log line
|
|
pins ``overview=True``); the best-effort test swaps in a spy that
|
|
raises ``LLMError``."""
|
|
spy = _OverviewSpy()
|
|
monkeypatch.setattr(git_sources_api, "regenerate_overview", spy)
|
|
return spy
|
|
|
|
|
|
def _point_at(monkeypatch: pytest.MonkeyPatch, sources_dir: Path, upload_dir: Path) -> None:
|
|
"""Fresh settings on the router's module: the tmp sources dir and
|
|
upload dir — the dev ``.env`` never leaks in, and a rmtree can
|
|
never aim at a real checkout/upload."""
|
|
monkeypatch.setattr(
|
|
git_sources_api,
|
|
"get_settings",
|
|
lambda: Settings(
|
|
_env_file=None, # pyright: ignore[reportCallIssue]
|
|
sources_dir=str(sources_dir),
|
|
upload_dir=str(upload_dir),
|
|
),
|
|
)
|
|
|
|
|
|
def _seed_doc(db: Session, source: str, path: str = "alpha.md") -> None:
|
|
"""One indexed document (+ two chunks) under ``source`` — the prune
|
|
+ cascade target. Seed directly (the task allows it): the import
|
|
pipeline is out of scope here."""
|
|
doc = Document(
|
|
source=source,
|
|
path=path,
|
|
full_path=f"/srv/brain/{source}/{path}",
|
|
title="Alpha",
|
|
content="# Alpha\nsentinel content\n",
|
|
content_hash=uuid.uuid4().hex,
|
|
)
|
|
db.add(doc)
|
|
db.flush()
|
|
db.add_all(
|
|
[
|
|
Chunk(document_id=doc.id, position=0, content="chunk zero"),
|
|
Chunk(document_id=doc.id, position=1, content="chunk one"),
|
|
]
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _seed_git_row(db: Session, url: str) -> GitSource:
|
|
row = GitSource(url=url, kind="git")
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
def _seed_local_row(db: Session, path: Path) -> GitSource:
|
|
"""A ``kind='local'`` row exactly as the create endpoint stores it
|
|
(phase 38: the expanded path mirrored in the NOT-NULL ``url``)."""
|
|
row = GitSource(url=str(path), kind="local", path=str(path))
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
def _version(db: Session) -> int:
|
|
"""The raw counter — a text query dodges the session identity map
|
|
(the bump commits in the endpoint's own short-lived session)."""
|
|
row = db.execute(text("SELECT version FROM sources_meta WHERE id = 1")).first()
|
|
return row[0] if row is not None else 0
|
|
|
|
|
|
def _docs(client: TestClient) -> list[tuple[str, str]]:
|
|
body = client.get("/api/docs").json()
|
|
return [(d["source"], d["path"]) for d in body["documents"]]
|
|
|
|
|
|
def _rows(client: TestClient) -> list[dict[str, object]]:
|
|
"""The stored rows (the settings carry no env URLs, so the
|
|
empty-table fallback also reports ``[]`` — the assertions only ever
|
|
rely on the DB rows' ids/urls)."""
|
|
return client.get("/api/git-sources").json()["sources"]
|
|
|
|
|
|
def _removal_lines(caplog: pytest.LogCaptureFixture) -> list[str]:
|
|
return [
|
|
rec.getMessage()
|
|
for rec in caplog.records
|
|
if rec.getMessage().startswith("source removed: ")
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# git row: row + docs + checkout dir, one 204
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_git_removal_deletes_row_docs_and_checkout_dir(
|
|
admin_client: TestClient,
|
|
db: Session,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
overview_spy: _OverviewSpy,
|
|
) -> None:
|
|
sources = tmp_path / "sources"
|
|
_point_at(monkeypatch, sources, tmp_path / "uploads")
|
|
url = "https://gitlab.example.com/reese/homelab.git"
|
|
row = _seed_git_row(db, url)
|
|
checkout = sources / "homelab"
|
|
checkout.mkdir(parents=True)
|
|
marker = checkout / "marker.md"
|
|
marker.write_text("sentinel\n", encoding="utf-8")
|
|
_seed_doc(db, "homelab")
|
|
_seed_doc(db, "homelab", path="bravo.md")
|
|
version_before = _version(db)
|
|
|
|
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
|
|
r = admin_client.delete(f"/api/git-sources/{row.id}")
|
|
assert r.status_code == 204
|
|
assert r.content == b"" # the 204 no-body contract, unchanged
|
|
|
|
# The row is gone (and the table is empty → the env fallback shape,
|
|
# no env URLs set here).
|
|
body = admin_client.get("/api/git-sources").json()
|
|
assert body["from_env"] is True
|
|
assert body["sources"] == []
|
|
|
|
# The app-managed checkout dir is gone from disk — marker included.
|
|
assert not checkout.exists()
|
|
assert not marker.exists()
|
|
|
|
# The source's documents are gone from the index (admin /api/docs)…
|
|
assert _docs(admin_client) == []
|
|
# …and every chunk row too (the ``all, delete-orphan`` cascade).
|
|
assert db.execute(text("SELECT count(*) FROM chunks")).scalar_one() == 0
|
|
assert db.execute(text("SELECT count(*) FROM documents")).scalar_one() == 0
|
|
|
|
# The KB changed → exactly one version bump (phase 53) and exactly
|
|
# one (spied) overview call.
|
|
assert _version(db) == version_before + 1
|
|
assert overview_spy.calls == 1
|
|
|
|
# The per-operation INFO line (PLAN §9) with the counts.
|
|
lines = _removal_lines(caplog)
|
|
assert len(lines) == 1, lines
|
|
match = re.match(
|
|
r"^source removed: kind=git name=homelab docs_pruned=2 files_removed=yes "
|
|
r"overview=True total_ms=\d+$",
|
|
lines[0],
|
|
)
|
|
assert match, lines[0]
|
|
|
|
|
|
def test_git_removal_without_checkout_dir_is_a_noop_on_disk(
|
|
admin_client: TestClient,
|
|
db: Session,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
overview_spy: _OverviewSpy,
|
|
) -> None:
|
|
"""No checkout yet (never cloned) → the disk step is a silent no-op;
|
|
the row + index removal still lands, 204."""
|
|
sources = tmp_path / "sources"
|
|
_point_at(monkeypatch, sources, tmp_path / "uploads")
|
|
row = _seed_git_row(db, "https://gitlab.example.com/reese/ops.git")
|
|
_seed_doc(db, "ops")
|
|
version_before = _version(db)
|
|
|
|
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
|
|
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
|
|
|
|
assert not (sources / "ops").exists()
|
|
assert _docs(admin_client) == []
|
|
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
|
# Docs were pruned → the bump + the overview still run.
|
|
assert _version(db) == version_before + 1
|
|
assert overview_spy.calls == 1
|
|
line = _removal_lines(caplog)[0]
|
|
assert "files_removed=no" in line # the absent dir is the "no" case
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# local rows: upload folder removed, foreign dir never touched
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_local_upload_removal_deletes_row_docs_and_folder(
|
|
admin_client: TestClient,
|
|
db: Session,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
upload = tmp_path / "uploads"
|
|
_point_at(monkeypatch, tmp_path / "sources", upload)
|
|
folder = upload / "my-notes"
|
|
folder.mkdir(parents=True)
|
|
marker = folder / "readme.md"
|
|
marker.write_text("sentinel\n", encoding="utf-8")
|
|
row = _seed_local_row(db, folder)
|
|
_seed_doc(db, "my-notes")
|
|
version_before = _version(db)
|
|
|
|
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
|
|
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
|
|
|
|
# Row + index entries gone…
|
|
assert _rows(admin_client) == []
|
|
assert _docs(admin_client) == []
|
|
# …and the app-managed upload folder is gone from disk.
|
|
assert not folder.exists()
|
|
assert not marker.exists()
|
|
assert _version(db) == version_before + 1
|
|
line = _removal_lines(caplog)[0]
|
|
assert re.match(
|
|
r"^source removed: kind=local name=my-notes docs_pruned=1 files_removed=yes "
|
|
r"overview=True total_ms=\d+$",
|
|
line,
|
|
), line
|
|
|
|
|
|
def test_local_foreign_dir_removal_never_touches_disk(
|
|
admin_client: TestClient,
|
|
db: Session,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A ``kind='local'`` row pointing at the owner's own directory:
|
|
row + index entries go, the directory NEVER does (the locked
|
|
decision) — and the KB change still bumps the version."""
|
|
upload = tmp_path / "uploads"
|
|
_point_at(monkeypatch, tmp_path / "sources", upload)
|
|
foreign = tmp_path / "own" / "docs"
|
|
foreign.mkdir(parents=True)
|
|
marker = foreign / "precious.md"
|
|
marker.write_text("keep me\n", encoding="utf-8")
|
|
row = _seed_local_row(db, foreign)
|
|
_seed_doc(db, "docs") # the local source label = the dir's name
|
|
version_before = _version(db)
|
|
|
|
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
|
|
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
|
|
|
|
assert _rows(admin_client) == []
|
|
assert _docs(admin_client) == []
|
|
# The foreign directory — and its files — are exactly as they were.
|
|
assert foreign.is_dir()
|
|
assert marker.read_text(encoding="utf-8") == "keep me\n"
|
|
# Index change → the bump still lands; the disk step was skipped.
|
|
assert _version(db) == version_before + 1
|
|
line = _removal_lines(caplog)[0]
|
|
assert "files_removed=skipped" in line, line
|
|
|
|
|
|
def test_local_prefix_sibling_dir_is_not_contained(
|
|
admin_client: TestClient,
|
|
db: Session,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The containment edge end-to-end: ``upload_dir = tmp/'u'`` and a
|
|
local row at ``tmp/'u-evil'`` — the prefix-sharing sibling is NOT
|
|
under the upload dir, so its files are never deleted (the unit test
|
|
pins ``managed_dir_for``; this pins the endpoint honoring it)."""
|
|
upload = tmp_path / "u"
|
|
upload.mkdir()
|
|
_point_at(monkeypatch, tmp_path / "sources", upload)
|
|
evil = tmp_path / "u-evil"
|
|
evil.mkdir()
|
|
marker = evil / "marker.md"
|
|
marker.write_text("not an upload\n", encoding="utf-8")
|
|
row = _seed_local_row(db, evil)
|
|
_seed_doc(db, "u-evil")
|
|
|
|
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
|
|
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
|
|
|
|
assert _rows(admin_client) == []
|
|
assert _docs(admin_client) == []
|
|
assert evil.is_dir()
|
|
assert marker.read_text(encoding="utf-8") == "not an upload\n"
|
|
assert "files_removed=skipped" in _removal_lines(caplog)[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sibling guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sibling_guard_keeps_shared_docs_and_files_until_last_row(
|
|
admin_client: TestClient,
|
|
db: Session,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
overview_spy: _OverviewSpy,
|
|
) -> None:
|
|
"""``https://example.com/reese/r`` and ``https://example.com/reese/
|
|
r.git`` both index under ``r`` — the first removal deletes ONLY the
|
|
row (docs + shared checkout stay, loudly logged, no bump/overview);
|
|
the second is the total removal."""
|
|
sources = tmp_path / "sources"
|
|
_point_at(monkeypatch, sources, tmp_path / "uploads")
|
|
row_a = _seed_git_row(db, "https://example.com/reese/r")
|
|
row_b = _seed_git_row(db, "https://example.com/reese/r.git")
|
|
checkout = sources / "r"
|
|
checkout.mkdir(parents=True)
|
|
marker = checkout / "shared.md"
|
|
marker.write_text("belongs to both rows\n", encoding="utf-8")
|
|
_seed_doc(db, "r")
|
|
version_before = _version(db)
|
|
|
|
# --- first row: sibling guard — only the row goes -----------------
|
|
# INFO level so both the loudly-logged warning and the per-operation
|
|
# INFO line of this removal are captured.
|
|
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
|
|
assert admin_client.delete(f"/api/git-sources/{row_a.id}").status_code == 204
|
|
|
|
rows = _rows(admin_client)
|
|
assert [s["id"] for s in rows] == [str(row_b.id)]
|
|
assert rows[0]["url"] == "https://example.com/reese/r.git"
|
|
assert rows[0]["kind"] == "git" and rows[0]["path"] is None
|
|
# The shared documents + files still belong to the sibling…
|
|
assert _docs(admin_client) == [("r", "alpha.md")]
|
|
assert checkout.is_dir()
|
|
assert marker.read_text(encoding="utf-8") == "belongs to both rows\n"
|
|
# …and nothing KB-side happened: no prune, no bump, no overview.
|
|
assert _version(db) == version_before
|
|
assert overview_spy.calls == 0
|
|
line = _removal_lines(caplog)[0]
|
|
assert re.match(
|
|
r"^source removed: kind=git name=r docs_pruned=0 files_removed=skipped "
|
|
r"overview=False total_ms=\d+$",
|
|
line,
|
|
), line
|
|
# …and the guard is logged loudly (naming the row + the shared name).
|
|
warnings = [
|
|
rec.getMessage() for rec in caplog.records if rec.levelno == logging.WARNING
|
|
]
|
|
assert any(
|
|
"shares source name r" in w and str(row_a.id) in w for w in warnings
|
|
), warnings
|
|
|
|
# --- second row: the total removal, at last ------------------------
|
|
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
|
|
assert admin_client.delete(f"/api/git-sources/{row_b.id}").status_code == 204
|
|
|
|
assert _rows(admin_client) == []
|
|
assert _docs(admin_client) == []
|
|
assert not checkout.exists()
|
|
assert _version(db) == version_before + 1
|
|
assert overview_spy.calls == 1
|
|
assert "files_removed=yes" in _removal_lines(caplog)[1]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# version bump + best-effort overview gates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_overview_failure_is_best_effort_still_204(
|
|
admin_client: TestClient,
|
|
db: Session,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
overview_spy: _OverviewSpy,
|
|
) -> None:
|
|
"""A failing overview (LLM outage) never fails the delete: the 204
|
|
lands, the docs stay pruned, and the bump still lands — overview
|
|
first, bump second (mirroring ``_run_sync``)."""
|
|
sources = tmp_path / "sources"
|
|
_point_at(monkeypatch, sources, tmp_path / "uploads")
|
|
row = _seed_git_row(db, "https://gitlab.example.com/reese/notes.git")
|
|
checkout = sources / "notes"
|
|
checkout.mkdir(parents=True)
|
|
(checkout / "n.md").write_text("x\n", encoding="utf-8")
|
|
_seed_doc(db, "notes")
|
|
version_before = _version(db)
|
|
|
|
failing = _OverviewSpy(fail=LLMError("simulated lite-model outage"))
|
|
monkeypatch.setattr(git_sources_api, "regenerate_overview", failing)
|
|
|
|
# INFO level — the per-operation line (INFO) and the best-effort
|
|
# failure (ERROR) are both captured.
|
|
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
|
|
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
|
|
|
|
assert failing.calls == 1
|
|
assert _docs(admin_client) == []
|
|
assert not checkout.exists()
|
|
# The bump lands even though the best-effort overview failed…
|
|
assert _version(db) == version_before + 1
|
|
# …and the failure is logged, never fatal.
|
|
line = _removal_lines(caplog)[0]
|
|
assert "overview=False" in line, line
|
|
errors = [
|
|
rec.getMessage() for rec in caplog.records if rec.levelno >= logging.ERROR
|
|
]
|
|
assert any("overview regeneration failed" in e for e in errors), errors
|
|
|
|
|
|
def test_no_docs_pruned_means_no_overview_and_no_bump(
|
|
admin_client: TestClient,
|
|
db: Session,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
overview_spy: _OverviewSpy,
|
|
) -> None:
|
|
"""A row with no indexed documents: the checkout dir is still
|
|
removed (files_removed=yes) but the KB is unchanged — no overview
|
|
call, no version bump (the gate is docs pruned, not files)."""
|
|
sources = tmp_path / "sources"
|
|
_point_at(monkeypatch, sources, tmp_path / "uploads")
|
|
row = _seed_git_row(db, "https://gitlab.example.com/reese/bare.git")
|
|
checkout = sources / "bare"
|
|
checkout.mkdir(parents=True)
|
|
(checkout / "b.md").write_text("never imported\n", encoding="utf-8")
|
|
version_before = _version(db)
|
|
|
|
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
|
|
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
|
|
|
|
assert not checkout.exists()
|
|
assert _rows(admin_client) == []
|
|
assert _version(db) == version_before
|
|
assert overview_spy.calls == 0
|
|
line = _removal_lines(caplog)[0]
|
|
assert re.match(
|
|
r"^source removed: kind=git name=bare docs_pruned=0 files_removed=yes "
|
|
r"overview=False total_ms=\d+$",
|
|
line,
|
|
), line
|