Files
brain-of-reese/tests/unit/test_source_removal.py
ducoterra 137d5fa1a5
Build and Push Containers / build-and-push-app (push) Successful in 1m29s
Build and Push Containers / build-and-push-db (push) Successful in 11s
feat(sources): removing a source deletes its files and index entries behind a confirmation modal
2026-09-02 15:55:33 -04:00

311 lines
11 KiB
Python

"""Unit: the total-removal helpers (phase 69, task 01).
Covers ``app.rag.source_removal`` with plain objects and ``tmp_path``
(no FastAPI, no database):
* ``resolve_source_name`` — exactly the sync/importer document labels:
git URL shapes (https ``.git`` / bare, scp-style ``git@host:repo.git``,
``ssh://``) and local paths (``~`` expansion, trailing slash, the
``path or url`` fallback);
* ``managed_dir_for`` — git → ``sources_dir/<repo>/``; local → the
stored dir only when it is ``upload_dir`` itself or nested under it
(the containment check, so a sibling named ``uploads-foo`` never
counts); any other local path → ``None`` (owner's own dir, never
touched);
* ``remove_managed_dir`` — ``None``/absent no-op (no filesystem write),
present tree removed → ``True``, ``OSError`` logged (``logger.
exception``) and returned as ``False`` — never raises;
* ``has_sibling`` — same-name sibling (git ``…/r`` vs ``…/r.git``, and
across kinds) → ``True``; different names → ``False``; self-excluded.
"""
from __future__ import annotations
import logging
import uuid
from pathlib import Path
from typing import cast
import pytest
from sqlalchemy.orm import Session
from app.models import GitSource
from app.rag import source_removal
from app.rag.source_removal import (
has_sibling,
managed_dir_for,
remove_managed_dir,
resolve_source_name,
)
def _git(url: str) -> GitSource:
return GitSource(id=uuid.uuid4(), url=url, kind="git")
def _local(path: str, path_column: str | None = None) -> GitSource:
# Phase 38 mirrors the expanded path in the NOT-NULL ``url`` column;
# ``path_column=None`` exercises the ``row.path or row.url`` fallback.
return GitSource(id=uuid.uuid4(), url=path, kind="local", path=path_column)
# ---------------------------------------------------------------------------
# resolve_source_name
# ---------------------------------------------------------------------------
def test_resolve_git_url_https_dot_git_suffix() -> None:
assert resolve_source_name(_git("https://example.com/reese/homelab.git")) == "homelab"
def test_resolve_git_url_https_bare() -> None:
assert resolve_source_name(_git("https://example.com/reese/homelab")) == "homelab"
def test_resolve_git_url_scp_style_git_at() -> None:
"""``git@host:repo.git`` — the ``:`` basename split (the phase-28
``repo_name`` behavior, reused not re-implemented)."""
assert resolve_source_name(_git("git@github.com:reese/deployments.git")) == "deployments"
assert resolve_source_name(_git("git@github.com:reese/deployments")) == "deployments"
def test_resolve_git_url_ssh_scheme() -> None:
assert resolve_source_name(_git("ssh://git@example.com/reese/ops.git")) == "ops"
def test_resolve_git_url_strips_whitespace() -> None:
assert resolve_source_name(_git(" https://example.com/reese/x.git ")) == "x"
def test_resolve_local_expands_tilde(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home" / "notes").mkdir(parents=True)
assert resolve_source_name(_local("~/notes")) == "notes"
def test_resolve_local_trailing_slash_and_nested() -> None:
assert resolve_source_name(_local("/srv/docs/notes/")) == "notes"
assert resolve_source_name(_local("/srv/a/b/c")) == "c"
def test_resolve_local_falls_back_to_url_column_when_path_null() -> None:
"""Phase 38 mirrors the path in ``url`` — the ``or`` fallback keeps a
NULL ``path`` row resolvable the same way."""
assert resolve_source_name(_local("/srv/docs/notes", path_column=None)) == "notes"
# ---------------------------------------------------------------------------
# managed_dir_for
# ---------------------------------------------------------------------------
def test_managed_dir_git_maps_to_sources_dir_repo_name(tmp_path: Path) -> None:
sources = tmp_path / "sources"
row = _git("https://example.com/reese/homelab.git")
assert managed_dir_for(row, sources, tmp_path / "uploads") == sources / "homelab"
def test_managed_dir_git_expands_tilde_sources_dir(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home" / "bor-sources").mkdir(parents=True)
row = _git("git@github.com:reese/ops.git")
got = managed_dir_for(row, Path("~/bor-sources"), tmp_path / "uploads")
assert got == tmp_path / "home" / "bor-sources" / "ops"
def test_managed_dir_local_upload_dir_itself(tmp_path: Path) -> None:
upload = tmp_path / "uploads"
upload.mkdir()
row = _local(str(upload))
assert managed_dir_for(row, tmp_path / "sources", upload) == upload
def test_managed_dir_local_nested_under_upload_dir(tmp_path: Path) -> None:
upload = tmp_path / "uploads"
folder = upload / "my-notes"
folder.mkdir(parents=True)
row = _local(str(folder))
assert managed_dir_for(row, tmp_path / "sources", upload) == folder
def test_managed_dir_local_deeply_nested_under_upload_dir(tmp_path: Path) -> None:
upload = tmp_path / "uploads"
folder = upload / "a" / "b"
folder.mkdir(parents=True)
row = _local(str(folder))
assert managed_dir_for(row, tmp_path / "sources", upload) == folder
def test_managed_dir_unresolvable_path_is_none(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Containment that cannot be established (an unresolvable path) is
``None`` — never delete when in doubt (the defensive branch)."""
row = _local("/srv/docs/notes")
def boom(self: Path, strict: bool = False) -> Path: # noqa: ARG001
raise OSError("simulated resolve failure")
monkeypatch.setattr(Path, "resolve", boom)
assert managed_dir_for(row, tmp_path / "sources", tmp_path / "uploads") is None
def test_managed_dir_local_foreign_path_is_none(tmp_path: Path) -> None:
"""The owner's own directory — never app-managed, never touched."""
foreign = tmp_path / "own" / "docs"
foreign.mkdir(parents=True)
row = _local(str(foreign))
assert managed_dir_for(row, tmp_path / "sources", tmp_path / "uploads") is None
def test_managed_dir_local_prefix_sibling_never_counts(tmp_path: Path) -> None:
"""The containment edge: ``…/u-evil`` is NOT under ``…/u`` — a
prefix-sharing sibling name must never map into the upload dir."""
evil = tmp_path / "u-evil"
evil.mkdir()
row = _local(str(evil))
assert managed_dir_for(row, tmp_path / "sources", tmp_path / "u") is None
def test_managed_dir_local_symlink_escaping_upload_dir_is_none(tmp_path: Path) -> None:
"""A symlink stored under the upload dir that points at the owner's
dir resolves OUTSIDE — containment fails → ``None`` (never delete
through a link)."""
upload = tmp_path / "uploads"
foreign = tmp_path / "foreign"
upload.mkdir()
foreign.mkdir()
link = upload / "sneaky"
link.symlink_to(foreign)
row = _local(str(link))
assert managed_dir_for(row, tmp_path / "sources", upload) is None
def test_managed_dir_local_under_upload_dir_symlink_still_maps(tmp_path: Path) -> None:
"""Mirror image: a link that stays under the upload dir resolves
inside it — the stored path is still the app-managed dir."""
upload = tmp_path / "uploads"
real = upload / "real"
upload.mkdir()
real.mkdir()
link = upload / "alias"
link.symlink_to(real)
row = _local(str(link))
assert managed_dir_for(row, tmp_path / "sources", upload) == link
# ---------------------------------------------------------------------------
# remove_managed_dir
# ---------------------------------------------------------------------------
def test_remove_managed_dir_none_is_noop_false() -> None:
assert remove_managed_dir(None) is False
def test_remove_managed_dir_absent_is_noop_false(tmp_path: Path) -> None:
missing = tmp_path / "never-created"
assert remove_managed_dir(missing) is False
assert not missing.exists() # no filesystem write
def test_remove_managed_dir_present_tree_removed_true(tmp_path: Path) -> None:
tree = tmp_path / "homelab"
(tree / "sub").mkdir(parents=True)
(tree / "alpha.md").write_text("one")
(tree / "sub" / "bravo.md").write_text("two")
assert remove_managed_dir(tree) is True
assert not tree.exists()
def test_remove_managed_dir_oserror_logged_not_fatal(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A permission/busy-dir failure is ``logger.exception`` + ``False``
— removal never raises (the DB removal is already committed)."""
stuck = tmp_path / "stuck"
stuck.mkdir()
(stuck / "alpha.md").write_text("one")
def boom(directory: Path) -> None:
raise OSError(13, "Permission denied", str(directory))
monkeypatch.setattr(source_removal.shutil, "rmtree", boom)
with caplog.at_level(logging.ERROR, logger="app.rag.source_removal"):
assert remove_managed_dir(stuck) is False
# The exception was logged (with the traceback) and the dir is as it
# was (the rmtree never ran) — inert, self-heals on re-add.
assert stuck.is_dir()
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1
assert "could not remove the on-disk directory" in errors[0].getMessage()
assert errors[0].exc_info is not None
# ---------------------------------------------------------------------------
# has_sibling
# ---------------------------------------------------------------------------
class _FakeScalars:
def __init__(self, rows: list[GitSource]) -> None:
self._rows = rows
def all(self) -> list[GitSource]:
return list(self._rows)
class _FakeSession:
"""The duck-typed seam: ``has_sibling`` only calls
``db.scalars(select(GitSource))`` (the statement builds fine without
a connection) — the fake returns the registry rows it was given."""
def __init__(self, rows: list[GitSource]) -> None:
self._rows = rows
def scalars(self, statement: object) -> _FakeScalars: # noqa: ARG002
return _FakeScalars(self._rows)
def _sibling(rows: list[GitSource], row: GitSource) -> bool:
"""``has_sibling`` against a fake registry session — the duck-typed
fake goes through the seam via ``cast`` (the ``test_agent.py``
pattern)."""
return has_sibling(cast("Session", _FakeSession(rows)), row)
def test_has_sibling_same_name_git_dot_git_pair() -> None:
a = _git("https://example.com/reese/r")
b = _git("https://example.com/reese/r.git")
assert _sibling([a, b], a) is True
assert _sibling([a, b], b) is True
def test_has_sibling_different_names_is_false() -> None:
a = _git("https://example.com/reese/one.git")
b = _git("https://example.com/reese/two.git")
assert _sibling([a, b], a) is False
assert _sibling([a, b], b) is False
def test_has_sibling_self_excluded() -> None:
a = _git("https://example.com/reese/only.git")
assert _sibling([a], a) is False
def test_has_sibling_empty_registry_is_false() -> None:
a = _git("https://example.com/reese/only.git")
assert _sibling([], a) is False
def test_has_sibling_across_kinds_same_resolved_name() -> None:
"""A git URL whose ``repo_name`` equals a local dir name is a
sibling too — both index under the same label."""
git_row = _git("https://example.com/reese/notes.git")
local_row = _local("/srv/docs/notes")
assert _sibling([git_row, local_row], git_row) is True
assert _sibling([git_row, local_row], local_row) is True