Files
brain-of-reese/tests/e2e/test_prune_cascade_cleanup.py
T
ducoterra c054b47df5
Build and Push Containers / build-and-push-app (push) Successful in 1m47s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 187_sync_peak_memory_line
**Phase 187 — final verification pass: all criteria met, repo 100% green.**

Verified (all four tasks already in `complete/`)
- `app/core/memory.py`: `rss_mb` / `rss_peak_mb` / `cgroup_peak_mb` / `fmt_mb`, pure stdlib, never-raise, **100%** coverage, 24 branch tests.
- Lifecycle lines append-only: `sync: started … rss_mb=`, all three terminals `… rss_mb= rss_peak_mb=` (failed = sanitized one-liner beside the surviving `logger.exception`), `import: summary … rss_mb=`; prefixes byte-identical.
- `GET /api/sync/status`: additive `rss_mb`/`rss_peak_mb` (`int|null`), keyed off the ONE `_sample_rss` (run start, phase transitions, forced terminals, 5 s-throttled hook), `peak >= current` invariant, anonymous 403.
- `sources.js` `fmtSyncPeakClause`: ` · peak <N> MB` trailing the counts line / "Sync cancelled", nothing when null, no new control/live region.

Gates (exact commands)
- `uv run pytest --cov=app --cov-report=term-missing` → **4570 passed**, TOTAL **99%**, `app/api/sync.py` **100%**, `app/core/memory.py` **100%**
- `uv run ruff check .` → clean · `uv run pyright` → **0 errors**
- `uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov` → **5 passed in isolation** (ran twice)
- All 27 touched E2E families re-run **in isolation**, all green (progress_box 8, cancel 3, status_freshness 4, summary_visibility 3, sync_button 3, session_reset 4, git_freeze 2, git_log_stream_dates 5, PDF/image suites, …)

Completion criteria: every checkbox ✅ except the commit + phase-dir move, which belong to the harness.

Documentation
- README reviewed in full (pages, commands, config tables cross-checked against `app/config.py` — 85 env rows, all defaults match; phase-187 ops section, greps, process-vs-cgroup caveat): consistent, no changes needed.
- `AGENTS.md`: **fixed one pre-existing inconsistency** — rule 6 claimed the no-CDN test covers "the index page"; it actually parametrizes 10 pages (all but `/doc-edit.html`).

Notable
- Two failures early in the pass were self-inflicted by running the E2E module concurrently with the full suite on the shared Postgres (TRUNCATE deadlock + one order-dependent `test_overview` flake); both vanish under the documented serial isolation — no code defect, no change made.
- **PLAN §9 still needs the owner's reconciliation** (phase 187 adds the sync memory fields + two status keys; PLAN is never agent-edited — AGENTS rule 10 carries the spec). `TODO.md` holds an unrelated owner note, left untouched.

Next pending phase: `188_chunks_hnsw_index`.
2026-10-16 15:22:51 -04:00

669 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 183 E2E (Playwright, mock-only): a mass prune leaves nothing behind.
Source: owner incident 2026-09-15 → 2026-10-15 — the brain-of-code sync
pruned **3,008 litellm docs** and the app hit 4 GB of RAM, locking up a
3,904 MB VM (no swap, sharing RAM with a 3.1 GB Postgres) hard enough that
boot −1 has no shutdown record at all. The cause was NOT the delete itself:
``Document.chunks`` carried ``cascade="all, delete-orphan"`` with **no
``passive_deletes``**, so every ``session.delete(doc)`` first SELECTed the
document's whole chunk collection — hydrating every 768-dim
``chunks.embedding`` (~24.8 KiB of Python each vs 3 KiB on disk) purely to
be thrown away, and nothing was released before the single commit
(``app/db.py`` builds sessions with ``expire_on_commit=False``).
The fix under test: ``Document.chunks`` is now ``passive_deletes=True``, so
the rows go **in the database** through ``chunks_document_id_fkey``'s
``ON DELETE CASCADE`` (alembic 0001 L54) and Python never sees them. That
is a *display-of-work* change with exactly one owner-visible contract: the
prune must still remove **every chunk row of every pruned document** — zero
orphans — while changing nothing else (the counts line, the KB tree, the
surviving RAG). This module pins that contract end to end, through the
pipeline the owner actually drives (the Sources page Sync button) and
through the admin's total source removal.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_prune_cascade_cleanup.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is skipped — the gate is the deterministic
prune count + the exact orphan-row query, and test 2's grounding needs the
mock-calibrated relevance thresholds (the conftest convention).
Per-module app env (the ``test_local_directory_sources.py`` /
``test_force_push_resync.py`` module-app pattern): this module's app boots
on its OWN port (the conftest session app owns 8123 in a combined run; a
same-port second uvicorn dies on bind and would drive the wrong server),
against the mock LLM, with a dedicated ``BOR_SOURCES_DIR`` /
``BOR_PDF_PAGE_DIR`` under ``tmp_path_factory`` and ``BOR_GIT_SOURCES``
forced empty (the owner's local gitignored ``.env`` must never leak a real
source into the run — the registry rows this suite creates through the
authenticated API are the only sources). The fixture KB is a plain
(non-git) host directory per test under ``tmp_path`` — the app server runs
on the same host, so the path is visible to it — with the stable basename
:data:`SOURCE_NAME` (``kind='local'`` rows index under
``Path(path).name``, ``app/rag/source_removal.py``) so the SQL assertions
name one source whatever the pytest temp root is.
Isolation: the shared E2E Postgres is truncated BEFORE **and** AFTER every
test (``chunks, documents, query_log, kb_overview, git_sources,
sources_meta, folder_summaries``) — a leftover registry row would flip a
sync onto another suite's source, and a leftover document under this
suite's deterministic source name would survive the prune and falsify the
count assertions.
Test → observable mapping (Playwright Mapping Rule):
1. ``test_deleted_files_prune_without_orphans`` — sync 9 files (the counts
line reports "9 added"), delete 6 of them upstream, re-sync: the counts
line reports "0 added · 3 unchanged · 6 pruned", the 6 documents are
gone from the KB (the tree row is absent, ``GET /api/documents/content``
404s the pair, ``GET /api/docs`` holds exactly the 3 survivors), the
pruned documents' **chunk rows are all gone** (the test's own SQL — the
no-orphan half of the phase-183 D4 contract) and the surviving chunk
total is exactly the sum of the 3 survivors' chunks.
2. ``test_surviving_kb_still_answers_after_a_partial_prune`` — the same
pruned state answers a chat question about a SURVIVING document: the
turn settles with a real answer (no error state, no empty bubble, the
composer recovered) and the durable ``query_log`` row is NOT deflected
and records the surviving document — a partial prune never damages the
RAG.
3. ``test_source_removal_prunes_every_document_of_the_source`` — the admin
path (``DELETE /api/git-sources/{id}``, ``app/api/git_sources.py``): the
second mass-delete surface ``passive_deletes=True`` covers. Every
document AND every chunk row of the removed source is gone (the same
orphan query), the count matches what was indexed there, the row is
gone, and the OTHER registered source's documents and chunks are
untouched.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import time
import uuid
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, QueryLog
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead.
# Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_PRUNECASCADE", "8199"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
SOURCES_URL = "/sources.html"
#: The fixture directory's basename — the ``source`` the importer records
#: for a ``kind='local'`` row (``Path(path).name``), so it is the same
#: string in the DB whatever ``tmp_path`` this test got.
SOURCE_NAME = "prune-cascade"
KEEPER_NAME = "prune-keeper"
#: The fixture layout: 9 importable files across one folder, 6 of which
#: the test deletes upstream (the incident's shape — a bulk deletion in a
#: synced source — scaled to what a test sync walks in seconds).
KEEP_PATHS = [f"notes/keep-{n}.md" for n in (1, 2, 3)]
GONE_PATHS = [f"notes/gone-{n}.md" for n in range(1, 7)]
ALL_PATHS = KEEP_PATHS + GONE_PATHS
#: The sentinel inside the FIRST survivor — test 2's question is answerable
#: ONLY from that document, so a grounded, non-deflected turn proves the
#: surviving chunk rows are still retrievable after the prune.
SENTINEL_DOC = KEEP_PATHS[0]
SENTINEL = "warden-lighthouse-RESE-PRUNECASCADE-7f31"
QUESTION = "Which keeper tends the warden lighthouse beacon log?"
#: The mock LLM's byte-stable grounded-answer marker (``mock_llm.
#: compose_answer``'s fallback branch) — its presence proves a real,
#: grounded answer streamed (the DEFLECT_MODE branch answers with
#: different, deterministic text).
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: "Synced HH:MM" — the local-time last-result label (sources.js's
#: fmtSyncTime), any hour/minute.
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
"""The real app under test — per-module env: the mock LLM, the
mock-calibrated gate (so test 2's grounding is deterministic), an
empty ``BOR_GIT_SOURCES`` fallback, and scratch
``BOR_SOURCES_DIR`` / ``BOR_PDF_PAGE_DIR`` under
``tmp_path_factory`` (nothing in this suite clones a repo or renders a
PDF — the dirs are set so the run never writes into the operator's
own). The session app is never started in this isolated run, so no
port clash."""
if USE_REAL_LLM:
pytest.skip(
"mock-only suite — the grounding pin is the mock's byte-stable "
"answer on the mock-calibrated gate (E2E_REAL_LLM unsupported)"
)
scratch = tmp_path_factory.mktemp("bor_prune_cascade")
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate (the conftest pattern, phase 112/113): the
# token-overlap mock embeddings score far below the production 0.62,
# and BOTH floors must stay <= the threshold or the app refuses to
# boot (app/config.py's typo guards).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# No env fallback rows: the registry this suite writes through the
# authenticated API is the only source list, so every sync's counts
# are exactly this test's doing.
env["BOR_GIT_SOURCES"] = ""
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
env["BOR_PDF_PAGE_DIR"] = str(scratch / "pdf-pages")
proc = subprocess.Popen(
[
sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning",
],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
def _truncate_all() -> None:
"""Fresh registry + KB per test (the E2E isolation pattern,
``test_source_removal_cleanup.py`` / ``test_force_push_resync.py``).
``sources_meta`` (the KB generation counter) and
``folder_summaries`` reset with the KB — the sync's overview/bump is an
upsert, and a leftover folder summary would ride the very
``changed_paths`` feed the prune writes."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, kb_overview, "
"git_sources, sources_meta, folder_summaries"
)
)
db.commit()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
_truncate_all()
yield
_truncate_all()
# ---------------------------------------------------------------------------
# The fixture source
# ---------------------------------------------------------------------------
def _doc_body(path: str) -> str:
"""One small markdown file per fixture path. The FIRST survivor is the
ONLY file carrying the sentinel's words, so test 2's question resolves
to exactly one document (and the mock's token-overlap cosine —
``sqrt(shared/total)`` — clears the mock-calibrated 0.30 gate only for
that one)."""
if path == SENTINEL_DOC:
return (
"# Keeper log\n"
"\n"
"The keeper tends the warden lighthouse beacon log.\n"
f"Marker: {SENTINEL}\n"
)
return (
f"# {path}\n"
"\n"
f"One unrelated note from the {SOURCE_NAME} fixture source.\n"
)
def _make_source_dir(root: Path, name: str, paths: list[str]) -> Path:
"""A plain (non-git) directory of importable files — the shape the
owner's local sources have. Returns the registered root."""
base = root / name
for rel in paths:
target = base / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(_doc_body(rel), encoding="utf-8")
assert not (base / ".git").exists()
return base
# ---------------------------------------------------------------------------
# Helpers: the DB side of the contract (the phase-183 D4 assertions)
# ---------------------------------------------------------------------------
def _doc_ids(db: Session, source: str, paths: list[str]) -> list[str]:
"""The indexed document ids of ``(source, path)``, sorted."""
return sorted(
str(doc_id)
for (doc_id,) in db.execute(
select(Document.id).where(
Document.source == source, Document.path.in_(paths)
)
).all()
)
def _chunk_count(db: Session, document_ids: list[str]) -> int:
"""The chunk rows still belonging to ``document_ids`` — THE orphan
probe. Non-zero means the documents are gone and their embeddings (and
their FTS rows) stayed behind."""
if not document_ids:
return 0
total = db.scalar(
select(func.count())
.select_from(Chunk)
.where(Chunk.document_id.in_([uuid.UUID(x) for x in document_ids]))
)
return int(total or 0)
def _total_chunks(db: Session) -> int:
return int(db.scalar(select(func.count()).select_from(Chunk)) or 0)
def _chunk_counts_by_doc(
db: Session, source: str, paths: list[str] | None = None
) -> dict[str, int]:
"""``path`` → chunk count, for the surviving-total assertion."""
query = (
select(Document.path, func.count(Chunk.id))
.outerjoin(Chunk, Chunk.document_id == Document.id)
.where(Document.source == source)
.group_by(Document.path)
)
if paths is not None:
query = query.where(Document.path.in_(paths))
rows = db.execute(query).all()
return {str(path): int(count) for path, count in rows}
def _sources_in_db(db: Session) -> list[str]:
return sorted(
{
str(name)
for (name,) in db.execute(
select(Document.source).distinct()
).all()
}
)
# ---------------------------------------------------------------------------
# Helpers: the UI / API side
# ---------------------------------------------------------------------------
def _started_at(body: dict[str, Any]) -> float:
"""The status body's ``started_at`` as a wall-clock epoch (the server
stamps it in UTC, this process runs on the same host — the
``test_git_source_branch`` idiom)."""
started = body.get("started_at")
if not started:
return 0.0
try:
return datetime.fromisoformat(str(started)).timestamp()
except ValueError:
return 0.0
def _sync_from_sources_page(page: Page, app_url: str) -> dict[str, Any]:
"""Click the Sources page **Sync sources** button and wait for the
run's TERMINAL state (the ``test_force_push_resync._wait_sync_state``
pattern: the module app is shared across a test's two runs, so a
terminal is accepted only after a ``running`` tick was seen or its
``started_at`` post-dates the click). Settles the UI on success
(``#sync-label`` → "Synced HH:MM" — the never-stale contract, §7.4)."""
login(page, app_url, next=SOURCES_URL)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
btn = page.locator("#sync-btn")
expect(btn).to_be_visible()
clicked_at = time.time()
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…")
deadline = time.monotonic() + 90.0
saw_running = False
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = page.request.get(f"{app_url}/api/sync/status")
assert r.status == 200, r.text
body = r.json()
state = body["state"]
if state == "running":
saw_running = True
elif state in ("success", "failed") and (
saw_running or _started_at(body) >= clicked_at - 1.0
):
break
time.sleep(0.2)
else:
raise AssertionError(f"sync never reached a terminal state: {body}")
assert body["state"] == "success", f"sync failed: {body}"
expect(btn).to_be_enabled(timeout=15_000)
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
# The counts line is the owner-visible prune report (phase 150 — the
# single sync text surface, sources.js fmtSyncResult).
return body
def _expect_counts_line(page: Page, expected: str) -> None:
"""The Sources page's counts line reads exactly ``expected`` —
"9 added" / "0 added · 3 unchanged · 6 pruned"."""
expect(page.locator("#sync-progress-text")).to_have_text(counts_line(expected), timeout=15_000)
def _add_local_source(page: Page, app_url: str, path: Path) -> str:
"""Register a local directory through the authenticated API (the
phase-49 shape — the page's own form for this is gone; the admin
cookie rides ``page.request``). Returns the row id."""
r = page.request.post(
f"{app_url}/api/git-sources",
data={"kind": "local", "path": str(path)},
)
assert r.status == 201, f"expected 201 for {path}: {r.status} {r.text}"
row_id = str(r.json()["id"])
assert row_id
return row_id
def _api_docs(page: Page, app_url: str) -> list[dict[str, Any]]:
"""``GET /api/docs`` as the signed-in page (admin cookie rides along)."""
r = page.request.get(f"{app_url}/api/docs")
assert r.status == 200, r.text
return list(r.json()["documents"])
def _api_doc_pairs(page: Page, app_url: str) -> list[tuple[str, str]]:
return sorted((d["source"], d["path"]) for d in _api_docs(page, app_url))
def _api_sources(page: Page, app_url: str) -> list[str]:
"""The distinct ``source`` labels the KB holds, read through the
signed-in API (the same view the owner gets on the Sources page)."""
return sorted({d["source"] for d in _api_docs(page, app_url)})
def _drill_to_fixture_folder(page: Page) -> None:
"""Open the KB catalogue down to the fixture's ``notes`` folder (the
drill-down tree is the phase-97 shape, the
``test_force_push_resync._expect_fixture_row`` idiom)."""
crumb = page.locator("#kb-crumb")
if not crumb.is_hidden():
page.click("#kb-crumb a.kb-crumb-link")
page.click(f'#folders-tbody a.folder-link:text-is("{SOURCE_NAME}")')
page.click('#folders-tbody a.folder-link:text-is("notes")')
# ---------------------------------------------------------------------------
# 1. Upstream deletion + re-sync: the prune is exact and leaves no orphans
# ---------------------------------------------------------------------------
def test_deleted_files_prune_without_orphans(
page: Page, app_url: str, db_ready: None, tmp_path: Path
) -> None:
"""The incident's own shape, end to end: 9 files sync, 6 are deleted
upstream, the re-sync prunes exactly those 6 — through the Sources page
Sync button, with the counts line as the owner-visible report — and the
pruned documents' chunk rows are gone FROM THE DATABASE (the phase-183
D4 no-orphan contract, asserted by this test's own SQL)."""
page.set_default_timeout(30_000)
source = _make_source_dir(tmp_path, SOURCE_NAME, ALL_PATHS)
login(page, app_url, next=SOURCES_URL)
_add_local_source(page, app_url, source)
# --- sync 1: everything is new ------------------------------------
body = _sync_from_sources_page(page, app_url)
assert body["detail"]["added"] == len(ALL_PATHS), body
assert body["detail"]["pruned"] == 0, body
_expect_counts_line(page, f"{len(ALL_PATHS)} added")
assert _api_doc_pairs(page, app_url) == [
(SOURCE_NAME, path) for path in sorted(ALL_PATHS)
]
# The subject of the prune, captured BEFORE it happens: the 6 doomed
# document ids + every survivor's chunk count. The orphan query below
# needs ids that no longer exist by the time it runs.
with SessionLocal() as db:
gone_ids = _doc_ids(db, SOURCE_NAME, GONE_PATHS)
keep_counts = _chunk_counts_by_doc(db, SOURCE_NAME, KEEP_PATHS)
assert _chunk_count(db, gone_ids) > 0, "the doomed docs must be embedded"
assert len(gone_ids) == len(GONE_PATHS), "the doomed documents must exist first"
assert all(keep_counts[p] > 0 for p in KEEP_PATHS), keep_counts
assert sum(keep_counts.values()) > 0
# --- the upstream deletion (the test owns the source dir) ----------
for rel in GONE_PATHS:
(source / rel).unlink()
assert not any((source / rel).exists() for rel in GONE_PATHS)
# --- sync 2: the prune ---------------------------------------------
body = _sync_from_sources_page(page, app_url)
assert body["detail"]["pruned"] == len(GONE_PATHS), body
assert body["detail"]["added"] == 0, body
assert body["detail"]["updated"] == 0, body
assert body["detail"]["unchanged"] == len(KEEP_PATHS), body
_expect_counts_line(
page,
f"0 added · {len(KEEP_PATHS)} unchanged · {len(GONE_PATHS)} pruned",
)
# The KB no longer serves the deleted files: the catalogue row is gone,
# the content route 404s the pair, and the total is exactly 3.
_drill_to_fixture_folder(page)
expect(page.locator("#docs-tbody tr", has_text=KEEP_PATHS[0])).to_have_count(1)
expect(page.locator("#docs-tbody tr", has_text=GONE_PATHS[0])).to_have_count(0)
r = page.request.get(
f"{app_url}/api/documents/content"
f"?source={SOURCE_NAME}&path={GONE_PATHS[0]}"
)
assert r.status == 404, f"a pruned document must 404, got {r.status}: {r.text}"
docs = _api_docs(page, app_url)
assert len(docs) == len(KEEP_PATHS), docs
assert {d["path"] for d in docs} == set(KEEP_PATHS), docs
# THE phase-183 assertion: the pruned documents left NO chunk rows
# behind (the FK cascade, not the ORM, removed them) and the surviving
# chunk total is exactly the survivors' own chunks — nothing orphaned,
# nothing collateral-damaged.
with SessionLocal() as db:
assert _chunk_count(db, gone_ids) == 0, (
"pruned documents left orphan chunk rows — the FK cascade did "
"not run (Document.chunks' passive_deletes without ON DELETE "
"CASCADE)"
)
assert _doc_ids(db, SOURCE_NAME, GONE_PATHS) == []
after = _chunk_counts_by_doc(db, SOURCE_NAME, KEEP_PATHS)
assert after == keep_counts, (
f"the surviving documents' chunks changed across the prune: {after}"
)
assert _total_chunks(db) == sum(keep_counts.values())
assert _sources_in_db(db) == [SOURCE_NAME]
# ---------------------------------------------------------------------------
# 2. The surviving KB still answers after a partial prune
# ---------------------------------------------------------------------------
def test_surviving_kb_still_answers_after_a_partial_prune(
page: Page, app_url: str, db_ready: None, tmp_path: Path
) -> None:
"""The prune's other half: what stays must still work. With the 6 files
pruned, the chat answers a question that only the surviving
:data:`SENTINEL_DOC` can answer — a real streamed answer (no empty
bubble, no error state, the composer recovered) whose durable
``query_log`` row is NOT deflected and names the surviving document."""
page.set_default_timeout(30_000)
source = _make_source_dir(tmp_path, SOURCE_NAME, ALL_PATHS)
login(page, app_url, next=SOURCES_URL)
_add_local_source(page, app_url, source)
_sync_from_sources_page(page, app_url)
for rel in GONE_PATHS:
(source / rel).unlink()
body = _sync_from_sources_page(page, app_url)
assert body["detail"]["pruned"] == len(GONE_PATHS), body
# The chat turn, through the real shell.
page.goto(f"{app_url}/")
expect(page.locator("#message-input")).to_be_visible(timeout=30_000)
# A healthy KB: the offline banner stays hidden (the prune did not
# empty or break the index the page reports on).
expect(page.locator("#kb-banner")).to_be_hidden()
page.fill("#message-input", QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(QUESTION)
bubble = page.locator(".msg.brain .bubble").last
# Never an empty answer bubble, and the grounded (not deflected) mock
# answer streamed.
expect(bubble).not_to_have_text("", timeout=30_000)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
# Never stale: the composer recovers (§7.4), and no error state landed.
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
# The durable record: grounded on the SURVIVING document.
with SessionLocal() as db:
rows = db.scalars(select(QueryLog)).all()
assert len(rows) == 1, [r.question for r in rows]
row = rows[0]
assert row.question == QUESTION
assert row.deflected is False, (
f"a partial prune broke retrieval — the turn deflected "
f"(top_score={row.top_score}, fts_hits={row.fts_hits})"
)
assert f"{SOURCE_NAME}/{SENTINEL_DOC}" in row.sources, row.sources
assert all(GONE_PATHS[i] not in row.sources for i in range(len(GONE_PATHS))), (
f"a pruned document still grounds a turn: {row.sources}"
)
# ---------------------------------------------------------------------------
# 3. Total source removal: every document AND chunk row of the source
# ---------------------------------------------------------------------------
def test_source_removal_prunes_every_document_of_the_source(
page: Page, app_url: str, db_ready: None, tmp_path: Path
) -> None:
"""The second mass-delete surface the keyword covers
(``delete_git_source``, ``app/api/git_sources.py`` — the endpoint that
took 47 s for 2,303 immich docs on the incident day): removing a
registered source removes every one of its documents and chunks with
zero orphans, and the OTHER registered source stays byte-identical.
The endpoint's contract is ``204`` with **no body** (phase 69), so the
prune count is pinned against what the KB held immediately before the
DELETE rather than a response field."""
page.set_default_timeout(30_000)
doomed = _make_source_dir(tmp_path, SOURCE_NAME, ALL_PATHS)
keeper = _make_source_dir(
tmp_path, KEEPER_NAME, ["notes/keeper-1.md", "notes/keeper-2.md"]
)
login(page, app_url, next=SOURCES_URL)
doomed_id = _add_local_source(page, app_url, doomed)
_add_local_source(page, app_url, keeper)
_sync_from_sources_page(page, app_url)
assert _api_sources(page, app_url) == [SOURCE_NAME, KEEPER_NAME]
with SessionLocal() as db:
doomed_doc_ids = _doc_ids(db, SOURCE_NAME, ALL_PATHS)
doomed_chunks = _chunk_count(db, doomed_doc_ids)
keeper_before = _chunk_counts_by_doc(db, KEEPER_NAME)
assert len(doomed_doc_ids) == len(ALL_PATHS)
assert doomed_chunks > 0, "the fixture must embed chunks to prune"
r = page.request.delete(f"{app_url}/api/git-sources/{doomed_id}")
assert r.status == 204, f"expected 204, got {r.status}: {r.text}"
# Every document of the source is gone and left NO chunk rows; the
# remaining source is untouched, chunks included.
with SessionLocal() as db:
assert _chunk_count(db, doomed_doc_ids) == 0, (
"source removal left orphan chunk rows — the FK cascade did "
"not run (Document.chunks' passive_deletes without ON DELETE "
"CASCADE)"
)
assert (
db.scalar(
select(func.count())
.select_from(Document)
.where(Document.source == SOURCE_NAME)
)
or 0
) == 0
assert _sources_in_db(db) == [KEEPER_NAME]
assert _chunk_counts_by_doc(db, KEEPER_NAME) == keeper_before
assert _total_chunks(db) == sum(keeper_before.values())
# The registry row is gone too, and the API agrees with the DB.
r = page.request.get(f"{app_url}/api/git-sources")
assert r.status == 200, r.text
rows = r.json()["sources"]
assert doomed_id not in [row["id"] for row in rows], rows
assert len(rows) == 1 and rows[0]["path"] == str(keeper), rows
assert _api_doc_pairs(page, app_url) == [
(KEEPER_NAME, f"notes/keeper-{n}.md") for n in (1, 2)
]
# The doomed files stay on disk — a foreign local directory is never
# touched (the phase-69 locked decision, unaffected by phase 183).
assert doomed.is_dir() and all((doomed / p).is_file() for p in KEEP_PATHS)