**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**
**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs
**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)
**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).
**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.
**Next pending phase:** `123_chat_image_questions`.
1437 lines
58 KiB
Python
1437 lines
58 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 asyncio
|
||
import base64
|
||
import hashlib
|
||
import inspect
|
||
import itertools
|
||
import logging
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import delete, func, select, text
|
||
|
||
import app.api.docs as docs_api
|
||
import app.rag.importer as rag_importer
|
||
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
|
||
from app.rag.importer import import_sources
|
||
from app.rag.llm import LLMError
|
||
from app.rag.summarizer import DESCRIBE_PROMPT
|
||
from tests.fakes import FakeEmbedder
|
||
|
||
_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)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 122, task 02 — image ingest end-to-end (the full ``import_sources``
|
||
# pipeline against the real DB; task 06 finalizes the phase-122 suite here
|
||
# with the image route + content-endpoint shapes).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: A real 1×1 transparent PNG — the importer is content-agnostic (it
|
||
#: never parses the image), but a well-formed fixture keeps the tests
|
||
#: honest about what a real upload looks like.
|
||
PNG_1X1 = base64.b64decode(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||
)
|
||
|
||
|
||
def _image_llm[
|
||
ImageLLM: FakeEmbedder
|
||
](tmp_path, llm_cls: type[ImageLLM] = FakeEmbedder, **kwargs) -> ImageLLM:
|
||
"""A fake LLM with the phase-122 image knobs (toggle ON by default;
|
||
the image dir defaults under *tmp_path* unless overridden).
|
||
*llm_cls* (task 03) may be the mock vision client (``_MockVisionLLM``
|
||
below) for the no-seam-patch end-to-end path — the PEP 695 type
|
||
parameter keeps the helper's return type honest (``chat_models``).
|
||
"""
|
||
kwargs.setdefault("_env_file", None)
|
||
kwargs.setdefault("images", True)
|
||
kwargs.setdefault("image_dir", str(tmp_path / "images"))
|
||
llm = llm_cls()
|
||
llm.settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||
return llm
|
||
|
||
|
||
def _patch_description(monkeypatch, description) -> None:
|
||
"""Pin the task-02 seam (``rag_importer._describe_or_skip``) to
|
||
*description*. Task 03 fills the seam with the CHAT model's vision
|
||
call — these end-to-end mechanics do not change with it."""
|
||
|
||
async def _fake(llm, *, data, source, rel, full_path):
|
||
return description
|
||
|
||
monkeypatch.setattr(rag_importer, "_describe_or_skip", _fake)
|
||
|
||
|
||
def _cleanup_source(db, source: str) -> None:
|
||
for doc in db.scalars(select(Document).where(Document.source == source)).all():
|
||
db.delete(doc)
|
||
db.commit()
|
||
|
||
|
||
def test_import_sources_images_on_indexes_image_docs(
|
||
db, tmp_path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""``images=True`` end-to-end: a standalone image in a source becomes
|
||
a Document — bytes digested, the persistent copy in ``image_dir``
|
||
(``<doc-id>.png``), ``content`` = the (mock) description, and ONLY
|
||
that text is embedded (the content chunks + the phase-30 summary
|
||
chunk, 768 dims) — while a text doc in the same source stays a
|
||
plain text doc."""
|
||
source_root = tmp_path / "imgsource"
|
||
source_root.mkdir()
|
||
(source_root / "diagram.png").write_bytes(PNG_1X1)
|
||
(source_root / "notes.md").write_text("# Notes\n\nBody text.\n", encoding="utf-8")
|
||
llm = _image_llm(tmp_path)
|
||
_patch_description(monkeypatch, "A network diagram of the homelab VLANs.")
|
||
try:
|
||
summary = asyncio.run(
|
||
import_sources([source_root], llm, session=db, prune=True)
|
||
)
|
||
assert (summary.files, summary.added, summary.images_failed) == (2, 2, 0)
|
||
assert summary.formats == {"md": 1, "png": 1}
|
||
|
||
doc = db.scalar(
|
||
select(Document).where(
|
||
Document.source == source_root.name, Document.path == "diagram.png"
|
||
)
|
||
)
|
||
assert doc is not None, "the image file must become a document"
|
||
assert doc.is_image is True and doc.image_path is not None
|
||
assert doc.content == "A network diagram of the homelab VLANs."
|
||
assert doc.title == "diagram" # the non-markdown stem rule
|
||
copy = Path(doc.image_path)
|
||
assert copy.parent == Path(llm.settings.image_dir)
|
||
assert copy.name == f"{doc.id}.png"
|
||
assert copy.read_bytes() == PNG_1X1
|
||
|
||
# The ONLY embedded text is the description (the embedding model
|
||
# never sees pixels): every content chunk carries it, the
|
||
# phase-30 summary chunk exists + is embedded, 768 dims. Task
|
||
# 03: the description IS the summary (stored verbatim — no
|
||
# ``lite`` call, no pointer line), so the summary chunk mirrors
|
||
# ``doc.content`` exactly.
|
||
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
|
||
content_chunks = [c for c in chunks if not c.is_summary]
|
||
assert [c.content for c in content_chunks] == [doc.content]
|
||
assert doc.summary == doc.content
|
||
summary_chunks = [c for c in chunks if c.is_summary]
|
||
assert len(summary_chunks) == 1 and summary_chunks[0].position == -1
|
||
assert summary_chunks[0].content == doc.content
|
||
for c in chunks:
|
||
assert c.embedding is not None and len(c.embedding) == 768
|
||
|
||
# The text doc is untouched by the image machinery.
|
||
md = db.scalar(
|
||
select(Document).where(
|
||
Document.source == source_root.name, Document.path == "notes.md"
|
||
)
|
||
)
|
||
assert md is not None and md.is_image is False and md.image_path is None
|
||
finally:
|
||
_cleanup_source(db, source_root.name)
|
||
|
||
|
||
class _MockVisionLLM(FakeEmbedder):
|
||
"""The phase-122 mock VISION client (task 03): the CHAT model
|
||
answers the multimodal describe call (the image bytes' data URL)
|
||
with a fixed, retrieval-oriented description; text (``lite``) calls
|
||
keep the ``FakeEmbedder`` behaviour. The REAL
|
||
``rag_importer._describe_or_skip`` → ``summarizer.describe_image``
|
||
chain runs end-to-end against it (no seam patch); every chat
|
||
call's model is recorded (``chat_models``)."""
|
||
|
||
DESCRIPTION = (
|
||
"A network diagram of the homelab VLANs: the core switch, the "
|
||
"router, and three labeled subnets."
|
||
)
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.chat_models: list[str | None] = []
|
||
|
||
async def chat(self, messages, model=None):
|
||
self.chat_calls.append(list(messages))
|
||
self.chat_models.append(model)
|
||
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||
if isinstance(user, list):
|
||
# The phase-122 describe call — the multimodal message.
|
||
return self.DESCRIPTION
|
||
first = user.split()
|
||
return "Summary of " + (first[0] if first else "<empty>")
|
||
|
||
|
||
def test_import_sources_mock_vision_client_end_to_end(db, tmp_path) -> None:
|
||
"""Task 03 end-to-end (NO seam patch): a fixture PNG through the
|
||
mock vision client — the real ``_describe_or_skip`` →
|
||
``describe_image`` → CHAT-model call — yields a doc whose
|
||
``content`` == ``summary`` == the description, with its
|
||
``is_summary`` chunk embedded, and whose ONLY embedded text is that
|
||
description (the embedding model never sees pixels)."""
|
||
source_root = tmp_path / "visione2e"
|
||
source_root.mkdir()
|
||
(source_root / "diagram.png").write_bytes(PNG_1X1)
|
||
llm = _image_llm(tmp_path, _MockVisionLLM)
|
||
try:
|
||
summary = asyncio.run(
|
||
import_sources([source_root], llm, session=db)
|
||
)
|
||
assert (summary.files, summary.added, summary.images_failed) == (1, 1, 0)
|
||
# The describe call went to the CHAT model (LOCKED A3), once.
|
||
assert llm.chat_models == [llm.settings.llm_chat_model]
|
||
|
||
# The wire shape: the multimodal user message — the fixed
|
||
# prompt's text part + the image's data-URL part.
|
||
(message,) = llm.chat_calls[0]
|
||
assert message["role"] == "user"
|
||
content: Any = message["content"] # the multimodal part list
|
||
assert content[0] == {"type": "text", "text": DESCRIBE_PROMPT}
|
||
assert content[1]["type"] == "image_url"
|
||
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||
|
||
doc = db.scalar(
|
||
select(Document).where(
|
||
Document.source == source_root.name, Document.path == "diagram.png"
|
||
)
|
||
)
|
||
assert doc is not None, "the image file must become a document"
|
||
assert doc.is_image is True and doc.image_path is not None
|
||
assert doc.content == _MockVisionLLM.DESCRIPTION
|
||
assert doc.summary == _MockVisionLLM.DESCRIPTION # task 03: verbatim
|
||
|
||
# The ONLY embedded text of the doc is the description — twice
|
||
# (the one content chunk + the is_summary chunk), 768 dims.
|
||
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
|
||
summary_chunks = [c for c in chunks if c.is_summary]
|
||
assert len(summary_chunks) == 1 and summary_chunks[0].position == -1
|
||
assert all(c.content == _MockVisionLLM.DESCRIPTION for c in chunks)
|
||
for c in chunks:
|
||
assert c.embedding is not None and len(c.embedding) == 768
|
||
embedded = [t for batch in llm.calls for t in batch]
|
||
assert embedded == [_MockVisionLLM.DESCRIPTION] * 2
|
||
finally:
|
||
_cleanup_source(db, source_root.name)
|
||
|
||
|
||
class _MockVisionFailsLLM(FakeEmbedder):
|
||
"""A NON-VISION chat model (LOCKED A3's honest failure): the
|
||
multimodal describe call raises (the SDK errors — a chat model
|
||
without vision rejects the ``image_url`` part), text (``lite``)
|
||
calls keep the ``FakeEmbedder`` behaviour."""
|
||
|
||
async def chat(self, messages, model=None):
|
||
self.chat_calls.append(list(messages))
|
||
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||
if isinstance(user, list):
|
||
raise LLMError("simulated non-vision chat model (test sentinel)")
|
||
first = user.split()
|
||
return "Summary of " + (first[0] if first else "<empty>")
|
||
|
||
|
||
def test_import_sources_failing_vision_skips_image_keeps_sync_green(
|
||
db, tmp_path, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
"""LOCKED A3 fail-soft end-to-end (the task-06 integration pin):
|
||
a fixture PNG through a NON-VISION chat model — the real seam, no
|
||
patch — skips the image doc (``images_failed == 1``, NO row, NO
|
||
orphan copy — not even the image dir) while the TEXT doc in the
|
||
same source is indexed as usual (the sync completes, no row
|
||
mutation anywhere for the failed image)."""
|
||
source_root = tmp_path / "visionfail"
|
||
source_root.mkdir()
|
||
(source_root / "diagram.png").write_bytes(PNG_1X1)
|
||
(source_root / "notes.md").write_text("# Notes\n\nBody.\n", encoding="utf-8")
|
||
llm = _image_llm(tmp_path, _MockVisionFailsLLM)
|
||
try:
|
||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||
summary = asyncio.run(
|
||
import_sources([source_root], llm, session=db)
|
||
)
|
||
assert (summary.files, summary.added, summary.images_failed) == (2, 1, 1)
|
||
# The image: no row, no copy (the dir itself was never created).
|
||
assert (
|
||
db.scalar(
|
||
select(Document).where(
|
||
Document.source == source_root.name, Document.path == "diagram.png"
|
||
)
|
||
)
|
||
is None
|
||
)
|
||
assert not Path(llm.settings.image_dir).expanduser().exists()
|
||
# The text doc indexed as usual (the sync stayed green).
|
||
md = db.scalar(
|
||
select(Document).where(
|
||
Document.source == source_root.name, Document.path == "notes.md"
|
||
)
|
||
)
|
||
assert md is not None and md.is_image is False
|
||
# The importer's warning names the document (the PLAN §9 signal).
|
||
warnings = [
|
||
r
|
||
for r in caplog.records
|
||
if r.name == "app.importer" and "image description failed" in r.getMessage()
|
||
]
|
||
assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
|
||
assert f"source={source_root.name} path=diagram.png" in warnings[0].getMessage()
|
||
finally:
|
||
_cleanup_source(db, source_root.name)
|
||
|
||
|
||
def test_import_sources_images_off_ignores_and_prune_guard_protects(
|
||
db, tmp_path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""``images=False`` (the default) end-to-end: the walk ignores the
|
||
image file entirely (not counted, no row, no copy), and a
|
||
``prune=True`` run MUST NOT delete a pre-existing image doc — the
|
||
LOCKED prune guard (invisible to the walk ≠ deleted)."""
|
||
source_root = tmp_path / "imgsrc_off"
|
||
source_root.mkdir()
|
||
(source_root / "diagram.png").write_bytes(PNG_1X1)
|
||
_patch_description(monkeypatch, "A network diagram.")
|
||
try:
|
||
llm_on = _image_llm(tmp_path)
|
||
s_on = asyncio.run(import_sources([source_root], llm_on, session=db))
|
||
assert s_on.added == 1
|
||
doc = db.scalar(
|
||
select(Document).where(
|
||
Document.source == source_root.name, Document.path == "diagram.png"
|
||
)
|
||
)
|
||
assert doc is not None
|
||
copy = Path(doc.image_path)
|
||
assert copy.exists()
|
||
|
||
# Toggle OFF (a fresh fake on the code defaults): the walk is
|
||
# blind to the file, and prune protects the pre-existing image
|
||
# doc + its copy.
|
||
llm_off = FakeEmbedder() # Settings(_env_file=None) → images False
|
||
s_off = asyncio.run(
|
||
import_sources([source_root], llm_off, session=db, prune=True)
|
||
)
|
||
assert (s_off.files, s_off.added, s_off.pruned) == (0, 0, 0)
|
||
assert db.scalar(select(Document).where(Document.id == doc.id)) is not None
|
||
assert copy.exists(), "the copy survives with the doc"
|
||
finally:
|
||
_cleanup_source(db, source_root.name)
|
||
|
||
|
||
def test_import_sources_toggle_on_prunes_deleted_image_with_copy(
|
||
db, tmp_path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Toggle ON, the image file deleted: the normal prune runs — the
|
||
doc row AND its ``image_dir`` copy are removed (the copy's
|
||
lifecycle is tied to the row)."""
|
||
source_root = tmp_path / "imgsrc_prune"
|
||
source_root.mkdir()
|
||
(source_root / "diagram.png").write_bytes(PNG_1X1)
|
||
llm_on = _image_llm(tmp_path)
|
||
_patch_description(monkeypatch, "A network diagram.")
|
||
try:
|
||
asyncio.run(import_sources([source_root], llm_on, session=db))
|
||
doc = db.scalar(
|
||
select(Document).where(
|
||
Document.source == source_root.name, Document.path == "diagram.png"
|
||
)
|
||
)
|
||
assert doc is not None
|
||
copy = Path(doc.image_path)
|
||
assert copy.exists()
|
||
|
||
(source_root / "diagram.png").unlink()
|
||
s = asyncio.run(
|
||
import_sources([source_root], llm_on, session=db, prune=True)
|
||
)
|
||
assert (s.pruned, s.added, s.unchanged) == (1, 0, 0)
|
||
assert (
|
||
db.scalar(select(Document).where(Document.source == source_root.name))
|
||
is None
|
||
)
|
||
assert not copy.exists(), "the image_dir copy is deleted with the doc"
|
||
finally:
|
||
_cleanup_source(db, source_root.name)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 122 (task 04) — the image BYTES route + the content/tree wire
|
||
# affordance (the serve side of the image-document contract).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _seed_image_doc(
|
||
db,
|
||
tmp_path: Path,
|
||
*,
|
||
source: str = "ImgSrc",
|
||
path: str = "pic.png",
|
||
data: bytes = PNG_1X1,
|
||
content: str = "A red square on a white background.",
|
||
image_path: str | None = "auto",
|
||
doc_id: str | None = None,
|
||
) -> Document:
|
||
"""One ``is_image`` document row with its persistent copy (the
|
||
importer's ``image_dir`` layout) under *tmp_path*; ``image_path``
|
||
``"auto"`` writes the copy, ``None`` leaves the row without a copy
|
||
(the lost-copy corner)."""
|
||
doc = Document(
|
||
id=uuid.UUID(doc_id) if doc_id else uuid.uuid4(),
|
||
source=source,
|
||
path=path,
|
||
full_path=f"/tmp/{path}",
|
||
title=path.rsplit(".", 1)[0],
|
||
content=content,
|
||
content_hash=hashlib.sha256(data).hexdigest(),
|
||
indexed_at=datetime.now(UTC),
|
||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||
summary=content,
|
||
is_image=True,
|
||
)
|
||
if image_path == "auto":
|
||
copy = tmp_path / f"{doc.id}{Path(path).suffix}"
|
||
copy.write_bytes(data)
|
||
doc.image_path = str(copy)
|
||
elif image_path is not None:
|
||
doc.image_path = image_path
|
||
db.add(doc)
|
||
db.flush()
|
||
db.add_all(
|
||
[
|
||
Chunk(
|
||
document_id=doc.id, position=0, content=content, embedding=[0.01] * 768
|
||
),
|
||
Chunk(
|
||
document_id=doc.id,
|
||
position=-1,
|
||
content=content,
|
||
embedding=[0.01] * 768,
|
||
is_summary=True,
|
||
),
|
||
]
|
||
)
|
||
db.commit()
|
||
return doc
|
||
|
||
|
||
def _cleanup_kb(db) -> None:
|
||
"""Truncate the KB tables. The settle commit matters: SQLAlchemy
|
||
does NOT autoflush pending ORM objects before a raw ``text()``
|
||
statement — a pending chunks INSERT flushed *after* the TRUNCATE
|
||
would FK-violate (the document row is already gone), so any
|
||
pending state is committed (then truncated) first."""
|
||
db.commit()
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("ext", "mime"),
|
||
[
|
||
(".png", "image/png"),
|
||
(".jpg", "image/jpeg"),
|
||
(".jpeg", "image/jpeg"),
|
||
(".webp", "image/webp"),
|
||
(".gif", "image/gif"),
|
||
(".bmp", "image/bmp"),
|
||
],
|
||
)
|
||
def test_image_route_serves_exact_bytes_with_content_type(
|
||
admin_client: TestClient, db, tmp_path: Path, ext: str, mime: str
|
||
) -> None:
|
||
"""The serve contract: the route streams the EXACT stored bytes
|
||
(a per-extension sentinel — a mix-up between the six formats is
|
||
caught) with the extension's ``Content-Type`` (the
|
||
``IMAGE_MIMES`` map — one map, one truth) and
|
||
``Cache-Control: private, max-age=3600`` (content-hashed bytes —
|
||
long enough, bustable by re-upload)."""
|
||
_cleanup_kb(db)
|
||
try:
|
||
data = PNG_1X1 + ext.encode("ascii") # per-extension sentinel bytes
|
||
doc = _seed_image_doc(db, tmp_path, path=f"pic{ext}", data=data)
|
||
r = admin_client.get(f"/api/documents/{doc.id}/image")
|
||
assert r.status_code == 200
|
||
assert r.headers["content-type"] == mime # exact type per extension
|
||
assert r.headers["cache-control"] == "private, max-age=3600"
|
||
assert r.content == data # the exact uploaded bytes, nothing else
|
||
finally:
|
||
_cleanup_kb(db)
|
||
|
||
|
||
def test_image_route_404_matrix(admin_client: TestClient, db, tmp_path: Path) -> None:
|
||
"""Every non-servable case is 404 ``document not found`` (the
|
||
router's unknown-document shape — the same detail string the
|
||
content endpoint uses): a missing id, a MALFORMED id (an unparseable
|
||
string maps here, not to a 422 — a guessed id is an unknown
|
||
document), a text doc, an image doc whose ``image_path`` is NULL,
|
||
and a row whose copy was lost on disk (defensive — the row exists,
|
||
the bytes don't)."""
|
||
_cleanup_kb(db)
|
||
try:
|
||
_seed_doc(db, "TextSrc", "note.md", "Note", 1, datetime.now(UTC))
|
||
db.commit() # the module's _seed_doc leaves the row uncommitted
|
||
text_doc_id = db.scalar(
|
||
select(Document.id).where(
|
||
Document.source == "TextSrc", Document.path == "note.md"
|
||
)
|
||
)
|
||
no_copy = _seed_image_doc(db, tmp_path, path="nopy.png", image_path=None)
|
||
lost = _seed_image_doc(db, tmp_path, path="lost.png")
|
||
assert lost.image_path is not None # the "auto" copy was written
|
||
Path(lost.image_path).unlink() # the copy is lost (the row remains)
|
||
|
||
for doc_id in (
|
||
str(uuid.uuid4()), # missing id
|
||
"not-a-uuid", # malformed id → 404, not 422
|
||
str(text_doc_id), # text doc
|
||
str(no_copy.id), # image doc, image_path NULL
|
||
str(lost.id), # image doc, copy lost
|
||
):
|
||
r = admin_client.get(f"/api/documents/{doc_id}/image")
|
||
assert r.status_code == 404, doc_id
|
||
assert r.json() == {"detail": "document not found"}, doc_id
|
||
finally:
|
||
_cleanup_kb(db)
|
||
|
||
|
||
def test_image_route_requires_user_like_the_content_endpoint(
|
||
admin_client: TestClient, db, tmp_path: Path
|
||
) -> None:
|
||
"""Phase 79 posture (the task's "PUBLIC, like the document content
|
||
endpoint" — the content endpoint has been user-gated since phase
|
||
79; the ONLY anonymous surface is the shared chats, PLAN A10): an
|
||
anonymous caller gets 401 ``authentication required`` before any
|
||
row is read (a FRESH client — the module's fixture ``client``
|
||
stays unsigned here), a signed-in caller gets the bytes."""
|
||
_cleanup_kb(db)
|
||
try:
|
||
doc = _seed_image_doc(db, tmp_path)
|
||
anonymous = TestClient(fastapi_app)
|
||
r = anonymous.get(f"/api/documents/{doc.id}/image")
|
||
assert r.status_code == 401
|
||
assert r.json() == {"detail": "authentication required"}
|
||
# The signed-in client (admin) passes.
|
||
assert admin_client.get(f"/api/documents/{doc.id}/image").status_code == 200
|
||
finally:
|
||
_cleanup_kb(db)
|
||
|
||
|
||
def test_content_endpoint_exposes_the_image_affordance(
|
||
admin_client: TestClient, db, tmp_path: Path
|
||
) -> None:
|
||
"""The content endpoint (the viewer's data source): ``is_image`` is
|
||
ALWAYS present (text doc: false — the one new key; the wire shape
|
||
gains nothing else), and ``image_url`` — the bytes route's path —
|
||
is present for an image doc and ABSENT for a text doc (never null,
|
||
the ``DocContent`` omission rule)."""
|
||
_cleanup_kb(db)
|
||
try:
|
||
_seed_doc(db, "TextSrc", "note.md", "Note", 1, datetime.now(UTC))
|
||
db.commit() # the app's endpoint session reads committed data only
|
||
r = admin_client.get(
|
||
"/api/documents/content", params={"source": "TextSrc", "path": "note.md"}
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["is_image"] is False
|
||
assert "image_url" not in body # absent — never null (text doc)
|
||
|
||
doc = _seed_image_doc(db, tmp_path, source="ImgSrc", path="pic.png")
|
||
r = admin_client.get(
|
||
"/api/documents/content", params={"source": "ImgSrc", "path": "pic.png"}
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["is_image"] is True
|
||
assert body["image_url"] == f"/api/documents/{doc.id}/image"
|
||
assert body["content"] == body["summary"] # the description (task 03)
|
||
finally:
|
||
_cleanup_kb(db)
|
||
|
||
|
||
def _tree_file_nodes_all(sources) -> list[dict]:
|
||
"""Every file node of a tree response, walked recursively (all
|
||
sources — env-registered 0-document sources may join the response
|
||
when the ``git_sources`` table is truncated, and they carry no
|
||
file nodes; the assertions below hold over whatever files exist).
|
||
"""
|
||
files: list[dict] = []
|
||
|
||
def _walk(node: dict) -> None:
|
||
for child in node.get("children", ()):
|
||
if child["kind"] == "file":
|
||
files.append(child)
|
||
else:
|
||
_walk(child)
|
||
|
||
for source in sources:
|
||
_walk(source)
|
||
return files
|
||
|
||
|
||
def test_tree_image_file_node_affordance_and_text_node_byte_identical(
|
||
admin_client: TestClient, db, tmp_path: Path
|
||
) -> None:
|
||
"""The tree (the RAG view's single fetch): an image doc's file node
|
||
carries the thumbnail affordance (``is_image`` true,
|
||
``image_url`` = the bytes route's path, ``summary`` verbatim — the
|
||
RAG view's thumbnail ``alt``); EVERY text file node keeps the
|
||
pre-phase wire shape byte-identically (the six keys — no
|
||
``is_image``/``image_url``/``summary`` — the phase's
|
||
byte-identical criterion: the fields are row-driven, so a KB with
|
||
no image rows serializes exactly as pre-phase)."""
|
||
_truncate_tree_tables(db)
|
||
try:
|
||
base = datetime.now(UTC)
|
||
_seed_doc(db, "MixedSrc", "a.md", "A", 1, base)
|
||
img = _seed_image_doc(db, tmp_path, source="MixedSrc", path="pic.png")
|
||
|
||
r = admin_client.get("/api/docs/tree") # both seeds committed (_seed_image_doc)
|
||
assert r.status_code == 200
|
||
files = {f["path"]: f for f in _tree_file_nodes_all(r.json()["sources"])}
|
||
|
||
# Text node: byte-identical pre-phase wire shape (no image keys).
|
||
assert set(files["a.md"]) == {
|
||
"kind", "path", "title", "chunks", "created_at", "indexed_at"
|
||
}
|
||
|
||
# Image node: the affordance rides the node.
|
||
pic = files["pic.png"]
|
||
assert pic["is_image"] is True
|
||
assert pic["image_url"] == f"/api/documents/{img.id}/image"
|
||
assert pic["summary"] == "A red square on a white background."
|
||
finally:
|
||
_truncate_tree_tables(db)
|
||
|
||
|
||
def test_tree_with_no_image_rows_is_byte_identical(admin_client: TestClient, db) -> None:
|
||
"""The pre-phase KB (no ``is_image`` rows): the image-docs map is
|
||
empty and EVERY file node serializes in the pre-phase shape — the
|
||
row-driven fields introduce no wire change at all (the phase's
|
||
byte-identical criterion, the toggle irrelevant)."""
|
||
_truncate_tree_tables(db)
|
||
try:
|
||
base = datetime.now(UTC)
|
||
_seed_doc(db, "PlainSrc", "x.md", "X", 2, base)
|
||
db.commit() # the app's endpoint session reads committed data only
|
||
r = admin_client.get("/api/docs/tree")
|
||
assert r.status_code == 200
|
||
files = _tree_file_nodes_all(r.json()["sources"])
|
||
assert len(files) == 1 # the seeded doc (env sources carry no files)
|
||
assert set(files[0]) == {
|
||
"kind", "path", "title", "chunks", "created_at", "indexed_at"
|
||
}
|
||
finally:
|
||
_truncate_tree_tables(db)
|