Relax the phase-94 folder-summary scope rule from ≥ 2 documents to ≥ 1: a folder (or source root) is a candidate while ANY document lives under it, so single-file folders and single-file source roots get their own lite-written description. A row is now pruned only when its folder loses its last document (vanishes from the catalogue). The constant is the single source of truth, so the flip propagates to the generator's candidate set, the prune pass, the missing_folder_summaries gap probe (the next sync self-heals the new gaps), and the KB-tree summary_pending markers (1-doc folders / sources now read "Summary pending" until their row lands). Docstrings/comments across app/, scripts/import_docs.py, and the E2E fixtures updated to the ≥ 1 wording. Unit + integration tests updated to the new semantics (the pruned-below-minimum scenario is now a folder losing its LAST doc; single-doc folders are pinned as candidates/pending). Full suite: 2314 passed, app coverage 99%; ruff + pyright clean; folder-summary E2E stories pass in isolation (ls_tree_drilldown, sync_summary_visibility, kb_tree, kb_tree_nav, document_dates, oneshot_llm_retry).
826 lines
32 KiB
Python
826 lines
32 KiB
Python
"""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"]:
|
|
# Wire-additive (phase 106, task 05): the pre-date keys are all
|
|
# still there, joined by ``created_at`` (the document's creation
|
|
# date — the RAG view's ``Created`` column).
|
|
assert set(d) == {
|
|
"id", "source", "path", "title", "chunks", "created_at", "indexed_at"
|
|
}
|
|
assert isinstance(d["chunks"], int) and d["chunks"] >= 0
|
|
datetime.fromisoformat(d["created_at"]) # raises if not ISO-8601
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# 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`` — and (phase 106,
|
|
# task 05) by ``updated_at`` (the subtree's max document date, D9).
|
|
assert set(homelab) == {
|
|
"name", "documents", "updated_at", "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 True # 1 document — the ≥ 1 minimum, no row
|
|
assert zeta["documents"] == 1
|
|
assert zeta["summary_pending"] is True # 1 document — the ≥ 1 minimum, no row
|
|
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
|
|
≥ 1 AND no stored row — the NESTED 1-doc folder included (the ≥ 1
|
|
rule); false WITH a stored row (any — the endpoint cannot tell AI
|
|
from manual). 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 — a candidate at the ≥ 1 rule (no row)
|
|
_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"]
|
|
# Phase 106 (task 05): ``updated_at`` joins the source node keys.
|
|
assert set(homelab) == {
|
|
"name", "documents", "updated_at", "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 True # 1 doc — pending at the ≥ 1 rule
|
|
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 (a candidate at the ≥ 1
|
|
# rule), the root holds 4 — candidates (Alpha, ""), (Alpha, "a"),
|
|
# (Alpha, "a/b"), (Alpha, "c").
|
|
_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.",
|
|
("Alpha", "c"): "Alpha c.",
|
|
("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 0-document registered source (Gamma) and every
|
|
# node that still holds a row — incl. the 1-doc Alpha/c, whose
|
|
# stored row covers it (the ≥ 1 rule makes it a candidate, but a
|
|
# candidate WITH a row is not missing).
|
|
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`` — a summarizable folder
|
|
(≥ 1 doc at the current minimum; two keep the pair realistic)."""
|
|
_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
|
|
folder the generator has not written yet (its fail-soft miss, or
|
|
a pre-sync state): 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()
|
|
# No AI row yet (the generator has not run in this test).
|
|
assert _get_folder_row(db, "Homelab", "solo") is None
|
|
|
|
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)
|