feat(sources): removing a source deletes its files and index entries behind a confirmation modal
Build and Push Containers / build-and-push-app (push) Successful in 1m29s
Build and Push Containers / build-and-push-db (push) Successful in 11s

This commit is contained in:
2026-09-02 15:55:33 -04:00
parent 265e736b3d
commit 137d5fa1a5
24 changed files with 3489 additions and 118 deletions
@@ -0,0 +1,528 @@
"""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