"""Integration tests: GET /api/docs — empty shape + populated shape — and GET /api/docs/tree (phase 97, task 02 — the full recursive KB tree the RAG view renders: shape, ordering, counts, summaries, the 403 gate, the superset rule, and the stat-walk equivalence with ``GET /api/docs``). Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient. """ from __future__ import annotations import inspect import itertools import uuid from datetime import UTC, datetime, timedelta import pytest from fastapi.testclient import TestClient from sqlalchemy import delete, func, select, text import app.api.docs as docs_api from app.config import Settings from app.core import tokens as token_service from app.main import app as fastapi_app from app.models import Chunk, Document, FolderSummary, GitSource from app.rag import git_sources as rag_git_sources from app.rag.folder_summaries import missing_folder_summaries _TREE_TABLES = "chunks, documents, folder_summaries, git_sources" def _truncate_tree_tables(db) -> None: db.execute(text(f"TRUNCATE {_TREE_TABLES}")) db.commit() _counter = itertools.count() def _seed_doc(db, source: str, path: str, title: str, n_chunks: int, indexed_at: datetime) -> None: """One indexed document with *n_chunks* content chunks (unique hash).""" doc = Document( source=source, path=path, full_path=f"/tmp/{source}/{path}", title=title, content=f"# {title}\n\nBody.", content_hash=f"{next(_counter):064d}", indexed_at=indexed_at, ) db.add(doc) db.flush() db.add_all( Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768) for i in range(n_chunks) ) def _tree_file_nodes(sources) -> list[dict]: """Every file node of a tree response, walked recursively.""" files: list[dict] = [] for source in sources: for child in source["children"]: if child["kind"] == "file": files.append(child) else: files.extend(_tree_file_nodes([child])) return files def _tree_pending_keys(sources) -> set[tuple[str, str]]: """Every ``(source, folder_path)`` flagged ``summary_pending`` in a tree response — the SOURCE root rides ``folder_path = ""`` (the ``folder_summaries`` convention); walked recursively over the whole tree (the D3 set the cross-check compares against :func:`missing_folder_summaries`).""" keys: set[tuple[str, str]] = set() def _walk(source_name: str, node: dict) -> None: if node.get("summary_pending"): keys.add((source_name, node["path"] if node.get("kind") == "folder" else "")) for child in node.get("children", ()): _walk(source_name, child) for source in sources: _walk(source["name"], source) return keys def test_docs_empty_shape(admin_client, db) -> None: db.execute(text("TRUNCATE chunks, documents")) db.commit() r = admin_client.get("/api/docs") assert r.status_code == 200 assert r.json() == {"documents": []} def test_docs_populated_shape_sorted_with_chunk_counts(admin_client, db) -> None: db.execute(text("TRUNCATE chunks, documents")) db.commit() now = datetime.now(UTC) k8s = Document( source="Homelab", path="kubernetes.md", full_path="/tmp/kubernetes.md", title="Kubernetes Homelab Cluster", content="# Kubernetes Homelab Cluster\n\nTalos on 3 nodes.", content_hash="a" * 64, indexed_at=now, ) empty = Document( source="Deployments", path="empty.md", full_path="/tmp/empty.md", title="No Chunks Yet", content="two-phase: doc exists, embeddings pending", content_hash="b" * 64, indexed_at=now, ) db.add_all([empty, k8s]) db.flush() db.add_all( Chunk(document_id=k8s.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768) for i in range(3) ) db.commit() r = admin_client.get("/api/docs") assert r.status_code == 200 body = r.json() # Ordered by (source, path): Deployments < Homelab. assert [d["path"] for d in body["documents"]] == ["empty.md", "kubernetes.md"] by_path = {d["path"]: d for d in body["documents"]} k = by_path["kubernetes.md"] assert k["source"] == "Homelab" assert k["title"] == "Kubernetes Homelab Cluster" assert k["chunks"] == 3 datetime.fromisoformat(k["indexed_at"]) # raises if not valid ISO-8601 uuid.UUID(k["id"]) # raises if not a valid UUID assert by_path["empty.md"]["chunks"] == 0 # outerjoin → zero, not missing db.execute(text("TRUNCATE chunks, documents")) db.commit() def test_docs_response_matches_schema_shape(admin_client, db) -> None: r = admin_client.get("/api/docs") assert r.status_code == 200 body = r.json() assert set(body) == {"documents"} for d in body["documents"]: assert set(d) == {"id", "source", "path", "title", "chunks", "indexed_at"} assert isinstance(d["chunks"], int) and d["chunks"] >= 0 # -------------------------------------------------------------------- # GET /api/docs/tree (phase 97, task 02). # -------------------------------------------------------------------- def test_docs_tree_populated_shape_order_counts_summaries(admin_client, db) -> None: _truncate_tree_tables(db) base = datetime.now(UTC) # Registry order (added_at) is Homelab → Deployments — deliberately # NOT alphabetical (the registry order leads, the phase-97 rule). db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base)) db.add( GitSource( url="https://github.com/reese/Deployments.git", kind="git", added_at=base + timedelta(hours=1), ) ) _seed_doc(db, "Homelab", "k8s/talos.md", "Talos", 3, base) _seed_doc(db, "Homelab", "k8s/cluster.md", "Cluster", 2, base) _seed_doc(db, "Homelab", "k8s/helm/charts.md", "Charts", 1, base) _seed_doc(db, "Homelab", "root-note.md", "Root note", 4, base) _seed_doc(db, "Deployments", "deploy-a.md", "Deploy A", 1, base) _seed_doc(db, "Deployments", "deploy-b.md", "Deploy B", 0, base) db.add_all( [ FolderSummary(source="Homelab", folder_path="", summary="Homelab docs."), FolderSummary(source="Homelab", folder_path="k8s", summary="K8s stuff."), # A stored row for a folder with NO indexed descendants — # e.g. a manual row surviving the prune below the 2-doc # minimum (phase 97, task 01): the tree's folders come from # INDEXED paths (the existence rule), not from summary rows. FolderSummary(source="Deployments", folder_path="orphan", summary="Ghost."), ] ) db.commit() r = admin_client.get("/api/docs/tree") assert r.status_code == 200 body = r.json() assert set(body) == {"sources"} sources = body["sources"] assert [s["name"] for s in sources] == ["Homelab", "Deployments"] homelab, deployments = sources # Wire-additive (phase 98, task 03): the pre-pending keys are all # still there, joined by ``summary_pending``. assert set(homelab) == {"name", "documents", "summary", "summary_pending", "children"} assert homelab["documents"] == 4 # the whole recursive count assert homelab["summary"] == "Homelab docs." # the (source, "") row assert homelab["summary_pending"] is False # the stored root row covers it assert deployments["summary"] is None # no stored root row assert deployments["documents"] == 2 # Homelab: subfolder first (path order among folders), then the # root files; the nested folder k8s/helm recurses one level deeper. k8s, root_note = homelab["children"] assert k8s["kind"] == "folder" assert k8s["path"] == "k8s" assert k8s["documents"] == 3 # talos + cluster + charts (subtree) assert k8s["summary"] == "K8s stuff." helm, cluster, talos = k8s["children"] assert (helm["kind"], helm["path"], helm["documents"], helm["summary"]) == ( "folder", "k8s/helm", 1, None, ) assert [ (c["kind"], c["path"], c["title"], c["chunks"]) for c in (cluster, talos) ] == [("file", "k8s/cluster.md", "Cluster", 2), ("file", "k8s/talos.md", "Talos", 3)] chart = helm["children"][0] assert (chart["kind"], chart["path"], chart["title"], chart["chunks"]) == ( "file", "k8s/helm/charts.md", "Charts", 1, ) datetime.fromisoformat(chart["indexed_at"]) # valid ISO-8601 assert (root_note["kind"], root_note["path"], root_note["title"], root_note["chunks"]) == ( "file", "root-note.md", "Root note", 4, ) # Deployments: flat — direct files in catalog order, and the # ``orphan`` summary row does NOT create a folder node. assert [(c["kind"], c["path"]) for c in deployments["children"]] == [ ("file", "deploy-a.md"), ("file", "deploy-b.md"), ] _truncate_tree_tables(db) def test_docs_tree_403_anonymous(client, db) -> None: """Admin-only, like ``GET /api/docs``: anonymous → 403 ``admin only`` (the RAG view's anonymous gate never fetches the tree).""" _truncate_tree_tables(db) r = client.get("/api/docs/tree") assert r.status_code == 403 assert r.json() == {"detail": "admin only"} def test_docs_tree_empty_registry_and_catalog( admin_client, db, monkeypatch: pytest.MonkeyPatch ) -> None: """Nothing registered, nothing indexed → ``{"sources": []}``. "Empty registry" means the ``git_sources`` table AND the ``BOR_GIT_SOURCES`` env fallback are both empty — the dev ``.env`` names a source, so the resolver's ``get_settings`` is patched with a fresh ``Settings(_env_file=None, git_sources="")`` (the ``test_sync_api`` / ``test_git_sources_api`` pattern: the dev ``.env`` never leaks in).""" monkeypatch.setattr( rag_git_sources, "get_settings", lambda: Settings(_env_file=None, git_sources=""), # pyright: ignore[reportCallIssue] ) _truncate_tree_tables(db) r = admin_client.get("/api/docs/tree") assert r.status_code == 200 assert r.json() == {"sources": []} def test_docs_tree_indexed_only_source_after_registered(admin_client, db) -> None: """The superset rule: a registered 0-document source still lists (first — the registry leads), and indexed-only sources (documents whose source is not in ``git_sources`` — ad-hoc imports, removed but not-yet-pruned sources) trail in alphabetical order.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Alpha.git", kind="git", added_at=base)) _seed_doc(db, "Zeta", "z1.md", "Z1", 1, base) _seed_doc(db, "Midx", "m1.md", "M1", 2, base) db.commit() r = admin_client.get("/api/docs/tree") assert r.status_code == 200 sources = r.json()["sources"] assert [s["name"] for s in sources] == ["Alpha", "Midx", "Zeta"] alpha, midx, zeta = sources assert (alpha["documents"], alpha["children"], alpha["summary"]) == (0, [], None) assert alpha["summary_pending"] is False # 0 documents — never pending assert midx["documents"] == 1 assert midx["summary_pending"] is False # 1 document — below the minimum assert zeta["documents"] == 1 assert zeta["summary_pending"] is False # 1 document — below the minimum assert [c["path"] for c in midx["children"]] == ["m1.md"] _truncate_tree_tables(db) def test_docs_tree_summary_pending_on_source_and_folder_nodes(admin_client, db) -> None: """The endpoint returns ``summary_pending`` on SOURCE + FOLDER nodes (phase 98, task 03 — D3): true iff the recursive count is ≥ 2 AND no stored row; false WITH a stored row (any — the endpoint cannot tell AI from manual); false for a < 2-document folder (never pending) — including one NESTED. File nodes carry no flag.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base)) # k8s → 3 documents (talos + cluster + charts), NO stored row → pending _seed_doc(db, "Homelab", "k8s/talos.md", "Talos", 1, base) _seed_doc(db, "Homelab", "k8s/cluster.md", "Cluster", 1, base) # k8s/helm → 1 document — below the 2-doc minimum, never pending _seed_doc(db, "Homelab", "k8s/helm/charts.md", "Charts", 1, base) # wiki → 2 documents, WITH a stored row → not pending _seed_doc(db, "Homelab", "wiki/one.md", "One", 1, base) _seed_doc(db, "Homelab", "wiki/two.md", "Two", 1, base) db.add_all( [ # The 5-doc source root IS covered → the source node is not pending FolderSummary(source="Homelab", folder_path="", summary="Homelab docs."), FolderSummary(source="Homelab", folder_path="wiki", summary="Wiki pages."), ] ) db.commit() r = admin_client.get("/api/docs/tree") assert r.status_code == 200 (homelab,) = r.json()["sources"] assert set(homelab) == {"name", "documents", "summary", "summary_pending", "children"} assert homelab["summary"] == "Homelab docs." assert homelab["summary_pending"] is False # Direct subfolders in path order: k8s < wiki. k8s, wiki = [c for c in homelab["children"] if c["kind"] == "folder"] assert k8s["path"] == "k8s" assert k8s["summary"] is None assert k8s["summary_pending"] is True # 3 docs, no stored row helm = k8s["children"][0] assert (helm["kind"], helm["path"]) == ("folder", "k8s/helm") assert helm["summary_pending"] is False # 1 doc — never pending assert wiki["summary"] == "Wiki pages." assert wiki["summary_pending"] is False # 2 docs, but a stored row covers it # File nodes carry no pending flag at all (the file table has no # description column — D3's file exclusion). for node in (k8s, wiki, helm): for child in node["children"]: if child["kind"] == "file": assert "summary_pending" not in child _truncate_tree_tables(db) def test_docs_tree_pending_set_equals_missing_folder_summaries(admin_client, db) -> None: """The D3 cross-check (ONE concept end to end): with a PARTIAL summary table (some rows deleted — the phase-96 gap-fill pattern), the set of ``(source, folder_path)`` flagged pending in the fetched tree (source root = ``""``) equals :func:`missing_folder_summaries` — the marker can never drift from the gap-fill.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Alpha.git", kind="git", added_at=base)) db.add( GitSource( url="https://github.com/reese/Beta.git", kind="git", added_at=base + timedelta(hours=1), ) ) db.add( GitSource( url="https://github.com/reese/Gamma.git", kind="git", added_at=base + timedelta(hours=2), ) ) # Alpha: a/b holds 2 docs, c holds 1 (NEVER a candidate), the root # holds 4 — candidates (Alpha, ""), (Alpha, "a"), (Alpha, "a/b"). _seed_doc(db, "Alpha", "a/b/c1.md", "C1", 1, base) _seed_doc(db, "Alpha", "a/b/c2.md", "C2", 1, base) _seed_doc(db, "Alpha", "c/solo.md", "Solo", 1, base) _seed_doc(db, "Alpha", "top.md", "Top", 1, base) # Beta: x holds 2 docs, the root holds 2 — candidates # (Beta, ""), (Beta, "x"). _seed_doc(db, "Beta", "x/one.md", "One", 1, base) _seed_doc(db, "Beta", "x/two.md", "Two", 1, base) # Gamma: registered, 0 documents — no candidates at all. # Seed every candidate row, then DELETE two of them (the phase-96 # direct-row-deletion pattern — a fail-soft miss / cleared row). db.add_all( FolderSummary(source=s, folder_path=f, summary=t) for (s, f), t in { ("Alpha", ""): "Alpha root.", ("Alpha", "a"): "Alpha a.", ("Alpha", "a/b"): "Alpha a b.", ("Beta", ""): "Beta root.", ("Beta", "x"): "Beta x.", }.items() ) db.commit() db.execute( delete(FolderSummary).where( FolderSummary.source == "Alpha", FolderSummary.folder_path == "a" ) ) db.execute( delete(FolderSummary).where( FolderSummary.source == "Beta", FolderSummary.folder_path == "" ) ) db.commit() r = admin_client.get("/api/docs/tree") assert r.status_code == 200 pending = _tree_pending_keys(r.json()["sources"]) # THE cross-check: the marker set IS the gap-fill's candidate set. assert pending == set(missing_folder_summaries(db)) # And the explicit expectation (the test is readable without the # helper): exactly the two deleted keys, root riding "". assert pending == {("Alpha", "a"), ("Beta", "")} # Never flagged: the < 2-doc folder (Alpha/c), the 0-document # registered source (Gamma), and every node that still holds a row. assert ("Alpha", "c") not in pending assert not any(name == "Gamma" for name, _ in pending) assert ("Alpha", "a/b") not in pending assert ("Beta", "x") not in pending assert ("Alpha", "") not in pending _truncate_tree_tables(db) # -------------------------------------------------------------------- # PATCH /api/folders/summary (phase 97, task 03) — the admin # folder-description editor: update / create / source-root / clear / # double-clear, the 404s, the 403s, and the round-trip through # ``GET /api/docs/tree`` (task 02). # -------------------------------------------------------------------- #: A deliberately old stamp: a save must ADVANCE ``updated_at`` past it. OLDER_STAMP = datetime(2020, 1, 1, tzinfo=UTC) def _seed_folder_pair(db, source: str, folder: str, base: datetime) -> None: """Two documents under ``source/folder`` — the ≥ 2-document minimum a folder must hold to be summarizable.""" _seed_doc(db, source, f"{folder}/one.md", f"{folder} one", 1, base) _seed_doc(db, source, f"{folder}/two.md", f"{folder} two", 2, base) def _get_folder_row(db, source: str, folder: str) -> FolderSummary | None: return db.scalar( select(FolderSummary).where( FolderSummary.source == source, FolderSummary.folder_path == folder ) ) def test_folder_summary_update_ai_row_flips_manual_and_advances_stamp( admin_client: TestClient, db ) -> None: """Update an AI-written row: the text is replaced, ``manually_edited`` flips to true, ``updated_at`` advances past the seeded stamp — and a second save with different text updates IN PLACE (same PK, one row). The round-trip: ``GET /api/docs/tree`` shows the new text on the folder node.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base)) _seed_folder_pair(db, "Homelab", "k8s", base) db.add( FolderSummary( source="Homelab", folder_path="k8s", summary="AI text.", manually_edited=False, updated_at=OLDER_STAMP, ) ) db.commit() db.expire_all() r = admin_client.patch( "/api/folders/summary", json={"source": "Homelab", "folder_path": "k8s", "summary": " Owner words. "}, ) assert r.status_code == 200, r.text assert set(r.json()) == {"source", "folder_path", "summary"} assert r.json() == {"source": "Homelab", "folder_path": "k8s", "summary": "Owner words."} db.expire_all() row = _get_folder_row(db, "Homelab", "k8s") assert row is not None assert row.summary == "Owner words." # stripped before storing assert row.manually_edited is True # a manual row from this save on assert row.updated_at > OLDER_STAMP # fresh stamp, not the seeded one # The task-02 tree shows the owner's text on the folder node. tree = admin_client.get("/api/docs/tree").json() k8s = next(c for c in tree["sources"][0]["children"] if c["kind"] == "folder") assert (k8s["path"], k8s["summary"]) == ("k8s", "Owner words.") # A second save updates in place — same row, no second PK. r2 = admin_client.patch( "/api/folders/summary", json={"source": "Homelab", "folder_path": "k8s", "summary": "Owner words v2."}, ) assert r2.status_code == 200, r2.text assert r2.json()["summary"] == "Owner words v2." db.expire_all() assert db.scalar(select(func.count()).select_from(FolderSummary)) == 1 # one row row = _get_folder_row(db, "Homelab", "k8s") assert row is not None assert row.summary == "Owner words v2." assert row.manually_edited is True _truncate_tree_tables(db) def test_folder_summary_create_where_no_row_exists( admin_client: TestClient, db ) -> None: """A manual description can be CREATED where no row exists — a < 2-document folder the generator never wrote (or its fail-soft miss): insert, not update, with ``manually_edited = true``.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base)) _seed_doc(db, "Homelab", "solo/only.md", "Only", 1, base) # 1-doc folder db.commit() db.expire_all() assert _get_folder_row(db, "Homelab", "solo") is None # no AI row for < 2 docs r = admin_client.patch( "/api/folders/summary", json={"source": "Homelab", "folder_path": "solo", "summary": "One-off scripts."}, ) assert r.status_code == 200, r.text assert r.json() == { "source": "Homelab", "folder_path": "solo", "summary": "One-off scripts.", } db.expire_all() row = _get_folder_row(db, "Homelab", "solo") assert row is not None assert row.summary == "One-off scripts." assert row.manually_edited is True _truncate_tree_tables(db) def test_folder_summary_root_save_on_registered_zero_doc_source( admin_client: TestClient, db ) -> None: """``folder_path = ""`` is valid for ANY allowed source — a registered 0-document source (nothing indexed yet) still accepts a root description: the source check is registered-OR-indexed.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Empty.git", kind="git", added_at=base)) db.commit() r = admin_client.patch( "/api/folders/summary", json={"source": "Empty", "folder_path": "", "summary": "Docs incoming."}, ) assert r.status_code == 200, r.text assert r.json() == {"source": "Empty", "folder_path": "", "summary": "Docs incoming."} db.expire_all() row = _get_folder_row(db, "Empty", "") assert row is not None assert row.manually_edited is True _truncate_tree_tables(db) def test_folder_summary_source_root_round_trips_through_tree( admin_client: TestClient, db ) -> None: """The source root (``folder_path: ""``) round-trips through the task-02 tree endpoint: save → the source node carries the text; clear → it is null again.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base)) _seed_doc(db, "Homelab", "a/b.md", "B", 1, base) _seed_doc(db, "Homelab", "c.md", "C", 1, base) db.commit() r = admin_client.patch( "/api/folders/summary", json={"source": "Homelab", "folder_path": "", "summary": "All the homelab docs."}, ) assert r.status_code == 200, r.text assert r.json() == { "source": "Homelab", "folder_path": "", "summary": "All the homelab docs.", } tree = admin_client.get("/api/docs/tree").json() assert tree["sources"][0]["summary"] == "All the homelab docs." db.expire_all() assert _get_folder_row(db, "Homelab", "") is not None r = admin_client.patch( "/api/folders/summary", json={"source": "Homelab", "folder_path": "", "summary": " "}, ) assert r.status_code == 200, r.text assert r.json() == {"source": "Homelab", "folder_path": "", "summary": None} tree = admin_client.get("/api/docs/tree").json() assert tree["sources"][0]["summary"] is None _truncate_tree_tables(db) def test_folder_summary_clear_deletes_row_and_double_clear_is_noop( admin_client: TestClient, db ) -> None: """Empty/whitespace clears: the row is deleted — AI-written OR manual, either way it is gone (the next KB-changing sync regenerates an AI row: the reset path) — and the response ``summary`` is null. A second clear with no row is a 200 no-op. The task-02 tree shows null on both folder nodes after the clears.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base)) _seed_folder_pair(db, "Homelab", "k8s", base) _seed_folder_pair(db, "Homelab", "manual", base) db.add( FolderSummary( source="Homelab", folder_path="k8s", summary="AI text.", manually_edited=False, updated_at=OLDER_STAMP, ) ) db.add( FolderSummary( source="Homelab", folder_path="manual", summary="Owner text.", manually_edited=True, updated_at=OLDER_STAMP, ) ) db.commit() db.expire_all() for folder, blank in (("k8s", " "), ("manual", "")): # whitespace, then empty r = admin_client.patch( "/api/folders/summary", json={"source": "Homelab", "folder_path": folder, "summary": blank}, ) assert r.status_code == 200, r.text assert r.json() == {"source": "Homelab", "folder_path": folder, "summary": None} db.expire_all() assert db.scalar(select(func.count()).select_from(FolderSummary)) == 0 # both rows gone # A second clear (no row) is a 200 no-op. r = admin_client.patch( "/api/folders/summary", json={"source": "Homelab", "folder_path": "k8s", "summary": ""}, ) assert r.status_code == 200, r.text assert r.json() == {"source": "Homelab", "folder_path": "k8s", "summary": None} db.expire_all() assert db.scalar(select(func.count()).select_from(FolderSummary)) == 0 # still nothing # The task-02 tree shows null on both folder nodes after the clears. tree = admin_client.get("/api/docs/tree").json() nodes = { c["path"]: c for c in tree["sources"][0]["children"] if c["kind"] == "folder" } assert nodes["k8s"]["summary"] is None assert nodes["manual"]["summary"] is None _truncate_tree_tables(db) def test_folder_summary_404_unknown_source_folder_and_traversal( admin_client: TestClient, db ) -> None: """404s, in check order: an unknown source (neither registered nor indexed) wins over the folder check — including for a folder that EXISTS under another source; an unknown folder, a traversal folder path, and a file merely sharing a folder's name (no indexed descendant) are all ``folder not found``. Nothing is written.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base)) _seed_folder_pair(db, "Homelab", "real", base) _seed_doc(db, "Homelab", "note", "A file named like a folder", 1, base) db.commit() cases = ( # (source, folder_path, detail) ("Ghost", "", "source not found"), # unknown source, root ("Ghost", "real", "source not found"), # folder exists ELSEWHERE — source wins ("Homelab", "nope", "folder not found"), # unknown folder ("Homelab", "../../etc", "folder not found"), # traversal: no prefix match ("Homelab", "note", "folder not found"), # file named like a folder, no descendants ("Homelab", "real/", "folder not found"), # trailing slash: strict prefix rule ) for source, folder, detail in cases: r = admin_client.patch( "/api/folders/summary", json={"source": source, "folder_path": folder, "summary": "whatever"}, ) assert r.status_code == 404, (source, folder, r.status_code) assert r.json() == {"detail": detail}, (source, folder) db.expire_all() assert db.scalar(select(func.count()).select_from(FolderSummary)) == 0 # nothing written _truncate_tree_tables(db) def test_folder_summary_403_anonymous_and_token_user( client: TestClient, db ) -> None: """The ``require_admin`` gate: an anonymous caller AND a live access-token user who is not the admin (phase 79 token users exist) both get 403 ``admin only`` — the endpoint gate is the API-level defense in depth, not the RAG view's render gate.""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base)) _seed_folder_pair(db, "Homelab", "k8s", base) db.execute(text("TRUNCATE api_tokens")) db.commit() body = {"source": "Homelab", "folder_path": "k8s", "summary": "x"} try: # Anonymous (the shared ``client`` is unsigned in this module). r = client.patch("/api/folders/summary", json=body) assert r.status_code == 403 assert r.json() == {"detail": "admin only"} # A live access-token user who is not the admin (phase 79): # signed in via the public token login, the row is active. _row, plaintext = token_service.create_token(db, "pin-holder") db.commit() holder = TestClient(fastapi_app) s = holder.post("/api/token-auth", json={"token": plaintext}) assert s.status_code == 204, s.text r = holder.patch("/api/folders/summary", json=body) assert r.status_code == 403 assert r.json() == {"detail": "admin only"} finally: db.execute(text("TRUNCATE api_tokens")) db.commit() _truncate_tree_tables(db) def test_folder_summary_patch_never_constructs_an_llm_client() -> None: """Source pin (the house pattern): a folder description is NEVER embedded — no chunk, no retrieval role beyond the ``ls`` line — so the PATCH handler must never touch the LLM client (the deliberate contrast with the phase-57 document-summary re-embed).""" src = inspect.getsource(docs_api.update_folder_summary) assert "LLMClient" not in src def test_docs_tree_stat_walk_equivalence_with_flat_list(admin_client, db) -> None: """The RAG view computes its KB-wide stat cards by walking the in-memory tree — the walk must yield EXACTLY what ``GET /api/docs`` reports for the same data (the stat-card values are unchanged by the redesign).""" _truncate_tree_tables(db) base = datetime.now(UTC) db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base)) _seed_doc(db, "Homelab", "a/b/c.md", "C", 3, base) _seed_doc(db, "Homelab", "a/d.md", "D", 2, base) _seed_doc(db, "Homelab", "top.md", "Top", 5, base) _seed_doc(db, "Homelab", "solo.md", "Solo", 0, base) db.commit() tree = admin_client.get("/api/docs/tree").json() flat = admin_client.get("/api/docs").json() files = _tree_file_nodes(tree["sources"]) assert len(files) == len(flat["documents"]) # document count assert sum(f["chunks"] for f in files) == sum(d["chunks"] for d in flat["documents"]) # The tree's per-source counts match the flat list's per-source counts. for source in tree["sources"]: flat_count = sum( 1 for d in flat["documents"] if d["source"] == source["name"] ) assert source["documents"] == flat_count source_files = _tree_file_nodes([source]) assert sum(f["chunks"] for f in source_files) == sum( d["chunks"] for d in flat["documents"] if d["source"] == source["name"] ) _truncate_tree_tables(db)