phase: 167_api_payload_diet
All gates green. Final report: **Verified (phase 167 final pass — all 6 tasks already in `complete/`)** - Live re-measure of the headline criterion on the **1,000-doc live-replica corpus** (restored `data/bor_live_kb_replica.dump.sql` into a scratch DB `bor_audit`, alembic 0011→0027, dropped after; the shared dev DB was never touched): `GET /api/docs/tree` default depth=1 = **8,474 identity → 1,128 B with `Content-Encoding: gzip`** (< 40 KB bar; ~2 MB defect fixed), repeat load **304 / 0 bytes**; depth=8 265,147→23,515; node drill 20,844→1,065; `/api/docs` 246,366→45,104; `/api/documents/content` 892,124→60,776 (`LOOK`, accepted-with-why). Reproduces `AUDIT.md` to within 1 byte. - Transport contracts proven live: `POST /api/chat` **no** `Content-Encoding` + frames arriving live + budget line `… enc=identity duration_ms=7380 stream=1`; HTML still `no-cache` with the `?v=` rewrite (and gzipped); A20 headers on 200 **and** 304; ETag identical across identity/gzip; `depth=0|9|abc`→422; anonymous tree/node→403; unknown folder/source→404 `unknown node`. **Gates (all 100% green)** - `uv run pytest` → **3980 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (`compression.py`, `http_cache.py`, `response_budget.py` 100%; `api/docs.py` 99%, one defensive dedupe line). - `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - Phase + regression E2E **in isolation** (`--junitxml`, exit codes): `test_kb_tree_lazy_load` 11, `test_kb_tree` 8, `test_kb_tree_nav` 4, `test_navbar_refresh` 7, `test_document_dates` 6, `test_git_source_dates` 4, `test_sync_summary_visibility` 3, `test_table_ux_polish` 6, `test_sources_midstream_bug` 6, `test_ls_tree_drilldown` 3, `test_smoke` 3 — **0 failures/errors** (the earlier `-q -q` run had simply suppressed pytest's summary line). **Documentation** — README.md + AGENTS.md reviewed in full (1,436 / 80 lines): - Fixed (pre-existing, phase-167-exposed): README *Caching & deploys* claimed "One Starlette middleware (`app/core/caching.py`) applies the rule" while the section now describes three transport layers → rewritten to name caching/compression/http_cache, the real outermost→innermost order, and why `bytes=` is uncompressed. - AGENTS.md rule 5 (UI Structure Check) gained the phase-167 clause: a lazily-drilled level keeps the current level rendered, its in-flight `role="status"` / `role="alert"`+Retry row lives in the same bounded scroll panel, and stat cards read server `totals` (never a walk of a depth-limited tree). - Cross-checked and consistent: every `BOR_*` in `.env.example` is in the README table, every `Settings` field is documented, all 15 README-cited API routes exist in the app, no stale "one fetch / drill client-side" claim remains anywhere. **Notable decisions / deviations** - `compression.py` adds a documented `_RebufferSmallResponses` seam beyond the D1 sketch (the caching layer's `BaseHTTPMiddleware` re-framing otherwise defeats Starlette's `minimum_size` exemption and inflates tiny responses) — integration-pinned, math still Starlette's. - A node-route **404 = vanished folder → honest reset to top level** (per `00_phase.md` D4/D6), not task-06's "alert + Retry"; 500 shows alert+Retry with the level preserved — both pinned, nothing weakened. - Retrieval battery not measurable (shared E2E DB holds no corpus), but `git diff --name-only -- app/rag` is empty, so retrieval is unchanged by construction. `AUDIT.md` left byte-identical (it lives under `todo/`, off-limits to this pass); no git index/history commands run. **Next pending phase:** none — `167_api_payload_diet` is the only entry in `.agents/phases/todo/`; the harness moves it to `complete/` and commits.
This commit is contained in:
@@ -335,6 +335,39 @@ def test_chat_streams_deltas_then_done_with_sources(client, db, seeded_kb: FakeR
|
||||
assert "HONESTY GATE" in system["content"]
|
||||
|
||||
|
||||
def test_chat_turn_logs_one_budget_line_with_the_stream_flag(
|
||||
client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Phase 167 (task 05, D5): the SSE turn is measured like every other
|
||||
``/api/*`` response but with `` stream=1`` — ONE line, written when the
|
||||
stream ends, ``bytes=`` the whole streamed total (the frames, not one
|
||||
frame) and ``duration_ms`` the stream's life. The stream itself stays
|
||||
uncompressed (phase 167 A1, pinned above), so ``enc=identity`` here
|
||||
documents the wire, and the chat turn never bloats the log with one
|
||||
line per frame."""
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
_, _, frames = _stream_chat(client, QUESTION)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
assert frames[-1]["type"] == "done" # the stream ran to completion
|
||||
lines = [
|
||||
record.getMessage()
|
||||
for record in caplog.records
|
||||
if record.name == "app.response_budget" and " path=/api/chat " in record.getMessage()
|
||||
]
|
||||
assert len(lines) == 1, lines
|
||||
line = lines[0]
|
||||
assert line.endswith(" stream=1")
|
||||
match = re.search(r"status=(\d+) bytes=(\d+) enc=(\S+)", line)
|
||||
assert match is not None
|
||||
assert match.group(1) == "200"
|
||||
assert int(match.group(2)) > len(b"data: ") # a real total, not a stub
|
||||
assert match.group(3) == "identity"
|
||||
|
||||
|
||||
def test_chat_streams_thinking_before_deltas(client, db, seeded_kb: FakeRagLLM) -> None:
|
||||
"""Phase 17: ``thinking`` frames precede every ``delta`` frame and
|
||||
reassemble to the model's reasoning; the ``done`` contract is
|
||||
|
||||
@@ -15,12 +15,20 @@ qualifying row, ABSENT otherwise — never ``null``), plus the phase-163
|
||||
for a stamped strip, ABSENT for a pre-phase row and for every
|
||||
non-qualifying document).
|
||||
|
||||
Also the phase-167 (task 03) per-level drill route
|
||||
``GET /api/docs/tree/node`` (the node shape, the 403/404 matrix, the
|
||||
pin that its children equal the same level of the unlimited
|
||||
``/api/docs/tree?depth=8`` response) and the ETag revalidation of the
|
||||
three catalogue reads (200 + validator, conditional 304 with no body,
|
||||
the admin-PATCH bust — the LOCKED A4 proof — coexisting with gzip).
|
||||
|
||||
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import gzip
|
||||
import hashlib
|
||||
import inspect
|
||||
import itertools
|
||||
@@ -38,8 +46,9 @@ 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.core.security_headers import CSP
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Chunk, Document, FolderSummary, GitSource
|
||||
from app.models import Chunk, Document, FolderSummary, GitSource, SourcesMeta
|
||||
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
|
||||
@@ -218,10 +227,19 @@ def test_docs_tree_populated_shape_order_counts_summaries(admin_client, db) -> N
|
||||
)
|
||||
db.commit()
|
||||
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
# Phase 167 (task 02): this shape pin walks the FULL tree, so it
|
||||
# asks for the whole build explicitly — the endpoint's default is
|
||||
# the view's first paint (depth=1), depth=8 is the entire tree.
|
||||
r = admin_client.get("/api/docs/tree?depth=8")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"sources"}
|
||||
# ``totals`` joins the top-level keys (phase 167, additive).
|
||||
assert set(body) == {"sources", "totals"}
|
||||
assert body["totals"] == {
|
||||
"documents": 6,
|
||||
"chunks": 11,
|
||||
"last_indexed": base.isoformat(),
|
||||
}
|
||||
sources = body["sources"]
|
||||
assert [s["name"] for s in sources] == ["Homelab", "Deployments"]
|
||||
|
||||
@@ -310,7 +328,12 @@ def test_docs_tree_empty_registry_and_catalog(
|
||||
_truncate_tree_tables(db)
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"sources": []}
|
||||
# Phase 167 (task 02): ``totals`` rides even the empty tree —
|
||||
# 0 documents, 0 chunks, no last-indexed stamp.
|
||||
assert r.json() == {
|
||||
"sources": [],
|
||||
"totals": {"documents": 0, "chunks": 0, "last_indexed": None},
|
||||
}
|
||||
|
||||
|
||||
def test_docs_tree_indexed_only_source_after_registered(admin_client, db) -> None:
|
||||
@@ -367,7 +390,10 @@ def test_docs_tree_summary_pending_on_source_and_folder_nodes(admin_client, db)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
# Phase 167 (task 02): the nested ``k8s/helm`` node needs the full
|
||||
# build — depth=8 keeps this pin walking every level (the default
|
||||
# is the view's first paint only).
|
||||
r = admin_client.get("/api/docs/tree?depth=8")
|
||||
assert r.status_code == 200
|
||||
(homelab,) = r.json()["sources"]
|
||||
# Phase 106 (task 05): ``updated_at`` joins the source node keys.
|
||||
@@ -458,7 +484,10 @@ def test_docs_tree_pending_set_equals_missing_folder_summaries(admin_client, db)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
# Phase 167 (task 02): the cross-check walks EVERY node, so it
|
||||
# requests the full tree (the default depth ships only the view's
|
||||
# first level — truncated nodes still carry their own markers).
|
||||
r = admin_client.get("/api/docs/tree?depth=8")
|
||||
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.
|
||||
@@ -828,8 +857,14 @@ def test_docs_tree_stat_walk_equivalence_with_flat_list(admin_client, db) -> Non
|
||||
_seed_doc(db, "Homelab", "solo.md", "Solo", 0, base)
|
||||
db.commit()
|
||||
|
||||
tree = admin_client.get("/api/docs/tree").json()
|
||||
# Phase 167 (task 02): the walk needs EVERY file node, so it asks
|
||||
# for the full tree; the server ``totals`` must equal the same walk
|
||||
# over the flat list (the stat cards cannot drift either way).
|
||||
tree = admin_client.get("/api/docs/tree?depth=8").json()
|
||||
flat = admin_client.get("/api/docs").json()
|
||||
assert tree["totals"]["documents"] == len(flat["documents"])
|
||||
assert tree["totals"]["chunks"] == sum(d["chunks"] for d in flat["documents"])
|
||||
assert tree["totals"]["last_indexed"] == max(d["indexed_at"] for d in flat["documents"])
|
||||
|
||||
files = _tree_file_nodes(tree["sources"])
|
||||
assert len(files) == len(flat["documents"]) # document count
|
||||
@@ -1768,3 +1803,636 @@ def test_tree_with_no_image_rows_is_byte_identical(admin_client: TestClient, db)
|
||||
}
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 167 (task 01): transport gzip on the real app — the /api/docs/tree
|
||||
# payload (the TODO L5 2 MB defect) compresses, small responses stay
|
||||
# identity, and the A20 security-header + A19 page contracts survive the
|
||||
# new layer sitting between SecurityHeaders (outermost) and Caching.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_gzip_corpus(db, count: int = 50) -> None:
|
||||
""">= 50 indexed docs — the tree response lands well above the 860-byte
|
||||
floor (the production corpus that motivates the phase is ~1 k docs)."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
for i in range(count):
|
||||
_seed_doc(db, "gzip-corpus", f"docs/sub-{i % 5}/file-{i:02d}.md", f"Doc {i:02d}", 1, base)
|
||||
db.commit() # the endpoint session reads committed data only
|
||||
|
||||
|
||||
def test_docs_tree_ships_gzip_and_decompresses_to_the_documented_json(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""Accept-Encoding: gzip → Content-Encoding: gzip + Vary, and the raw
|
||||
wire bytes decompress to EXACTLY the identity bytes (the documented
|
||||
tree JSON, 50 file nodes) — ~an order of magnitude smaller."""
|
||||
_seed_gzip_corpus(db)
|
||||
# Phase 167 (task 02): the 50-file corpus must ride the wire for
|
||||
# this byte pin, so the full tree is requested explicitly (the
|
||||
# endpoint default ships one level, which would fall under the
|
||||
# compression floor).
|
||||
try:
|
||||
identity = admin_client.get(
|
||||
"/api/docs/tree?depth=8", headers={"Accept-Encoding": "identity"}
|
||||
)
|
||||
assert identity.status_code == 200
|
||||
assert "content-encoding" not in identity.headers
|
||||
assert len(identity.content) >= 860 # the seed is above the floor
|
||||
|
||||
r = admin_client.get(
|
||||
"/api/docs/tree?depth=8", headers={"Accept-Encoding": "gzip"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-encoding"] == "gzip"
|
||||
assert "accept-encoding" in r.headers.get("vary", "").lower()
|
||||
# httpx decodes transparently: same documented JSON.
|
||||
sources = r.json()["sources"]
|
||||
assert len(_tree_file_nodes_all(sources)) == 50
|
||||
# The RAW stream is a real gzip of the identity body.
|
||||
with admin_client.stream(
|
||||
"GET", "/api/docs/tree?depth=8", headers={"Accept-Encoding": "gzip"}
|
||||
) as streamed:
|
||||
raw = b"".join(streamed.iter_raw())
|
||||
assert gzip.decompress(raw) == identity.content
|
||||
assert len(raw) < len(identity.content) // 2 # repetitive JSON: >> 2x
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_gzipped_response_still_carries_the_a20_headers(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""The outer-layer invariant (A20): gzip sits INSIDE
|
||||
SecurityHeadersMiddleware, so a gzipped response still carries the
|
||||
CSP, nosniff and the no-framing headers."""
|
||||
_seed_gzip_corpus(db, 60)
|
||||
try:
|
||||
# depth=8 (phase 167, task 02): the whole tree stays above the
|
||||
# 860-byte floor — the point here is a GZIPPED response.
|
||||
r = admin_client.get("/api/docs/tree?depth=8", headers={"Accept-Encoding": "gzip"})
|
||||
assert r.headers["content-encoding"] == "gzip"
|
||||
assert r.headers["content-security-policy"] == CSP
|
||||
assert r.headers["x-content-type-options"] == "nosniff"
|
||||
assert r.headers["x-frame-options"] == "DENY"
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_small_api_response_stays_identity(client: TestClient) -> None:
|
||||
"""A tiny /api/* response (under the 860-byte floor) is NOT compressed
|
||||
even when the client offers gzip — the minimum-size exemption."""
|
||||
r = client.get("/api/health", headers={"Accept-Encoding": "gzip"})
|
||||
assert r.status_code == 200
|
||||
assert "content-encoding" not in r.headers
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_html_page_contract_survives_gzip(client: TestClient) -> None:
|
||||
"""A19 through the new layer: the rewritten HTML page (>= the floor, so
|
||||
it compresses) STILL serves no-cache with the ?v= asset rewrite and no
|
||||
validators — and the contract holds AFTER decompression."""
|
||||
r = client.get("/", headers={"Accept-Encoding": "gzip"})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-encoding"] == "gzip" # 95 KB page, far above the floor
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert "etag" not in r.headers
|
||||
assert "last-modified" not in r.headers
|
||||
assert r.text.count("?v=") >= 1 # the caching rewrite ran, gzip preserved it
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 167 (task 02): the depth-limited tree + server totals (D2/A2) — the
|
||||
# default depth is the RAG view's first paint (sources + ONE level), an
|
||||
# explicit depth builds deeper, out-of-range values are rejected, and an
|
||||
# untruncated node keeps its pre-phase-167 wire shape.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_depth_corpus(db) -> datetime:
|
||||
"""Four documents nested one level deeper each (root, ``a``, ``a/b``,
|
||||
``a/b/c``) with DISTINCT ``indexed_at`` stamps, plus one stored
|
||||
summary on ``a`` — a fixture whose truncated nodes still carry a
|
||||
recursive count, and whose level rows carry a description and a
|
||||
pending marker."""
|
||||
_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", "root.md", "Root", 1, base)
|
||||
_seed_doc(db, "Homelab", "a/top.md", "Top", 2, base + timedelta(hours=1))
|
||||
_seed_doc(db, "Homelab", "a/b/mid.md", "Mid", 3, base + timedelta(hours=2))
|
||||
_seed_doc(db, "Homelab", "a/b/c/deep.md", "Deep", 4, base + timedelta(hours=3))
|
||||
db.add(FolderSummary(source="Homelab", folder_path="a", summary="A desc."))
|
||||
db.commit() # the app's endpoint session reads committed data only
|
||||
return base
|
||||
|
||||
|
||||
def test_docs_tree_default_depth_ships_one_level_with_totals(admin_client, db) -> None:
|
||||
"""``GET /api/docs/tree`` (no param) is the view's first paint:
|
||||
sources + ONE level. The folder with unloaded children carries
|
||||
``truncated: true`` + ``children: []`` AND its recursive count;
|
||||
``totals`` matches ``GET /api/docs`` (documents, chunks, the newest
|
||||
``indexed_at``) — the stat cards stay honest on a partial tree."""
|
||||
base = _seed_depth_corpus(db)
|
||||
try:
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"sources", "totals"}
|
||||
(homelab,) = body["sources"]
|
||||
assert homelab["documents"] == 4 # the whole recursive source count
|
||||
assert "truncated" not in homelab # depth >= 1: never truncated
|
||||
folders = [c for c in homelab["children"] if c["kind"] == "folder"]
|
||||
files = [c for c in homelab["children"] if c["kind"] == "file"]
|
||||
assert [f["path"] for f in files] == ["root.md"] # direct files ship
|
||||
(a,) = folders
|
||||
assert a["path"] == "a"
|
||||
assert a["truncated"] is True
|
||||
assert a["children"] == []
|
||||
assert a["documents"] == 3 # the recursive count, no children built
|
||||
assert a["summary"] == "A desc." # the level row shows the description
|
||||
assert a["summary_pending"] is False # the stored row covers it
|
||||
# Nothing deeper is on the wire (the 2 MB payload stays home).
|
||||
assert [f["path"] for f in _tree_file_nodes_all(body["sources"])] == ["root.md"]
|
||||
# Totals from the SAME catalogue rows (no extra query).
|
||||
assert body["totals"] == {
|
||||
"documents": 4,
|
||||
"chunks": 10,
|
||||
"last_indexed": (base + timedelta(hours=3)).isoformat(),
|
||||
}
|
||||
flat = admin_client.get("/api/docs").json()
|
||||
assert body["totals"]["documents"] == len(flat["documents"])
|
||||
assert body["totals"]["chunks"] == sum(d["chunks"] for d in flat["documents"])
|
||||
assert body["totals"]["last_indexed"] == max(
|
||||
d["indexed_at"] for d in flat["documents"]
|
||||
)
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_docs_tree_depth_three_builds_deeper_and_truncates_the_rest(
|
||||
admin_client, db
|
||||
) -> None:
|
||||
"""``?depth=3``: ``a`` and ``a/b`` are built (no ``truncated`` key),
|
||||
``a/b``'s subfolder ``a/b/c`` is truncated — while ``a/b``'s OWN
|
||||
direct files still ship: the budget limits DEPTH, never the row set
|
||||
of the levels it does send. The phase-98 pending rule is unchanged
|
||||
on truncated nodes (``a/b/c``: 1 doc, no stored row → pending)."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
body = admin_client.get("/api/docs/tree?depth=3").json()
|
||||
(homelab,) = body["sources"]
|
||||
(a,) = [c for c in homelab["children"] if c["kind"] == "folder"]
|
||||
assert "truncated" not in a # expanded at this depth
|
||||
a_children = {c["path"]: c for c in a["children"]}
|
||||
assert set(a_children) == {"a/b", "a/top.md"}
|
||||
a_b = a_children["a/b"]
|
||||
assert "truncated" not in a_b
|
||||
a_b_c, mid = a_b["children"] # subfolder first, then the direct file
|
||||
assert (a_b_c["kind"], a_b_c["path"], a_b_c["truncated"]) == (
|
||||
"folder",
|
||||
"a/b/c",
|
||||
True,
|
||||
)
|
||||
assert (a_b_c["documents"], a_b_c["children"]) == (1, [])
|
||||
assert a_b_c["summary"] is None
|
||||
assert a_b_c["summary_pending"] is True # 1 doc, no stored row
|
||||
assert (mid["kind"], mid["path"]) == ("file", "a/b/mid.md")
|
||||
assert {f["path"] for f in _tree_file_nodes_all(body["sources"])} == {
|
||||
"root.md",
|
||||
"a/top.md",
|
||||
"a/b/mid.md",
|
||||
} # deep.md never reaches the wire at this depth
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_docs_tree_depth_validation_matrix(admin_client, db) -> None:
|
||||
"""``depth`` is bounded (1..8) and typed: ``0`` and ``9`` are out of
|
||||
range and ``abc`` is not an int — all 422 before any build runs;
|
||||
the bounds themselves answer 200."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
for bad in ("0", "9", "abc"):
|
||||
r = admin_client.get(f"/api/docs/tree?depth={bad}")
|
||||
assert r.status_code == 422, bad
|
||||
assert admin_client.get("/api/docs/tree?depth=1").status_code == 200
|
||||
assert admin_client.get("/api/docs/tree?depth=8").status_code == 200
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_docs_tree_full_build_keeps_the_pre_phase_node_wire_shape(
|
||||
admin_client, db
|
||||
) -> None:
|
||||
"""The wire-shape pin: on a FULL (``depth=8``) build nothing is
|
||||
truncated, so NO node carries the ``truncated`` key — the source,
|
||||
folder and file key sets are exactly the pre-phase-167 ones (the
|
||||
omission serializer keeps every existing consumer byte-identical);
|
||||
the additive top-level ``totals`` is the only body change."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
body = admin_client.get("/api/docs/tree?depth=8").json()
|
||||
assert set(body) == {"sources", "totals"}
|
||||
(homelab,) = body["sources"]
|
||||
assert set(homelab) == {
|
||||
"name", "documents", "updated_at", "summary", "summary_pending", "children"
|
||||
}
|
||||
seen_folders = seen_files = 0
|
||||
stack = list(homelab["children"])
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node["kind"] == "folder":
|
||||
seen_folders += 1
|
||||
assert set(node) == {
|
||||
"kind", "path", "documents", "updated_at", "summary",
|
||||
"summary_pending", "children",
|
||||
}
|
||||
stack.extend(node["children"])
|
||||
else:
|
||||
seen_files += 1
|
||||
assert set(node) == {
|
||||
"kind", "path", "title", "chunks", "created_at", "indexed_at"
|
||||
}
|
||||
assert (seen_folders, seen_files) == (3, 4)
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 167 (task 03): GET /api/docs/tree/node — the drill's per-level
|
||||
# fetch — and the ETag revalidation of the three catalogue reads
|
||||
# (D3, LOCKED A3/A4).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_child(node: dict, path: str) -> dict:
|
||||
"""The child of *node* whose ``path`` equals *path* (the level walk
|
||||
the drill does client-side, expressed once for the assertions)."""
|
||||
return next(child for child in node["children"] if child["path"] == path)
|
||||
|
||||
|
||||
def test_tree_node_source_root_is_the_kb_tree_source_shape(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""``?source=Homelab`` (``path`` omitted = ``""``; the explicit
|
||||
``path=`` answers the identical node): the SOURCE shape — the full
|
||||
key set, never ``truncated``, the whole recursive source count —
|
||||
with the root level only: the direct file plus the subfolder
|
||||
flagged for the next drill."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
r = admin_client.get("/api/docs/tree/node", params={"source": "Homelab"})
|
||||
assert r.status_code == 200
|
||||
node = r.json()
|
||||
assert "truncated" not in node # the node's OWN children are always built
|
||||
assert set(node) == {
|
||||
"name",
|
||||
"documents",
|
||||
"updated_at",
|
||||
"summary",
|
||||
"summary_pending",
|
||||
"children",
|
||||
}
|
||||
assert node["name"] == "Homelab"
|
||||
assert node["documents"] == 4 # the whole recursive source count
|
||||
assert node["summary_pending"] is True # no stored (Homelab, "") row
|
||||
(a,) = [c for c in node["children"] if c["kind"] == "folder"]
|
||||
(root,) = [c for c in node["children"] if c["kind"] == "file"]
|
||||
assert (a["path"], a["truncated"], a["children"], a["documents"]) == (
|
||||
"a",
|
||||
True,
|
||||
[],
|
||||
3,
|
||||
)
|
||||
assert root["path"] == "root.md"
|
||||
# The explicit empty path is the SAME node as the omitted one.
|
||||
r2 = admin_client.get(
|
||||
"/api/docs/tree/node", params={"source": "Homelab", "path": ""}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json() == node
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_tree_node_nested_folder_fields_and_depth_one_children(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""``?source=Homelab&path=a&depth=1``: the KbTreeFolder shape with
|
||||
every field the level row renders — recursive count, recursive
|
||||
``updated_at``, ``summary``, ``summary_pending`` (phase 98 D3) — and
|
||||
its DIRECT children only: the deeper subfolder flagged
|
||||
``truncated`` (the next drill's target), the direct file present."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
r = admin_client.get(
|
||||
"/api/docs/tree/node", params={"source": "Homelab", "path": "a"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
node = r.json()
|
||||
assert node["kind"] == "folder"
|
||||
assert node["path"] == "a"
|
||||
assert "truncated" not in node # this node's children WERE built
|
||||
assert node["documents"] == 3 # recursive — the phase-94 count rule
|
||||
assert node["updated_at"] is not None # recursive max (phase 106 D9)
|
||||
assert node["summary"] == "A desc."
|
||||
assert node["summary_pending"] is False # the stored row covers it
|
||||
a_b, top = node["children"] # subfolder first, then the direct file
|
||||
assert (a_b["kind"], a_b["path"], a_b["truncated"], a_b["children"]) == (
|
||||
"folder",
|
||||
"a/b",
|
||||
True,
|
||||
[],
|
||||
)
|
||||
assert a_b["documents"] == 2 # mid.md + deep.md, recursive
|
||||
assert a_b["summary_pending"] is True # docs but no stored row
|
||||
assert (top["kind"], top["path"]) == ("file", "a/top.md")
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_tree_node_depth_two_builds_one_level_deeper(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""``&depth=2`` spends one more level: ``a/b`` arrives BUILT (no
|
||||
``truncated`` key, its direct file present) and only its own
|
||||
subfolder stays truncated — the same budget semantics the full tree
|
||||
applies, answered for one node."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
r = admin_client.get(
|
||||
"/api/docs/tree/node",
|
||||
params={"source": "Homelab", "path": "a", "depth": 2},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
a_b = _find_child(r.json(), "a/b")
|
||||
assert "truncated" not in a_b # expanded at this depth
|
||||
a_b_c, mid = a_b["children"]
|
||||
assert (a_b_c["path"], a_b_c["truncated"], a_b_c["documents"]) == (
|
||||
"a/b/c",
|
||||
True,
|
||||
1,
|
||||
)
|
||||
assert (mid["kind"], mid["path"]) == ("file", "a/b/mid.md")
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_tree_node_404_unknown_source_folder_and_file_path(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""404 ``{"detail": "unknown node"}`` for a source that is neither
|
||||
registered nor indexed, a folder failing the phase-94 existence
|
||||
rule, a FILE's path (a document's OWN path is not a folder), and
|
||||
traversal strings — which have no filesystem surface to hit: the
|
||||
node is derived from catalogue ROWS alone (the row-only rule, the
|
||||
phase-10 traversal argument reused)."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
for params in (
|
||||
{"source": "nope"}, # neither registered nor indexed
|
||||
{"source": "Homelab", "path": "nope"}, # no indexed path starts with it
|
||||
{"source": "Homelab", "path": "a/top.md"}, # a FILE's own path
|
||||
{"source": "Homelab", "path": "../Homelab"}, # traversal is not a folder
|
||||
{"source": "..", "path": ""}, # nor is it a source
|
||||
):
|
||||
r = admin_client.get("/api/docs/tree/node", params=params)
|
||||
assert r.status_code == 404, params
|
||||
assert r.json() == {"detail": "unknown node"}
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_tree_node_403_anonymous(client: TestClient, db) -> None:
|
||||
"""The same gate as ``/api/docs/tree`` — anonymous 403
|
||||
``{"detail": "admin only"}`` (the RAG view never fetches for guests,
|
||||
the endpoint must not lean on that)."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
r = client.get("/api/docs/tree/node", params={"source": "Homelab"})
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_tree_node_validation_matrix(admin_client: TestClient, db) -> None:
|
||||
"""``source`` is required and ``depth`` keeps the tree route's
|
||||
bounds (``ge=1, le=8``): a missing source, ``depth=0``, ``depth=9``
|
||||
and ``depth=abc`` all 422 before anything is built."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
assert admin_client.get("/api/docs/tree/node").status_code == 422
|
||||
for bad in ("0", "9", "abc"):
|
||||
r = admin_client.get(
|
||||
"/api/docs/tree/node", params={"source": "Homelab", "depth": bad}
|
||||
)
|
||||
assert r.status_code == 422, bad
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_tree_node_children_equal_the_full_tree_level(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""ONE concept, two routes, pinned equal: at the same budget the
|
||||
node route answers EXACTLY what ``GET /api/docs/tree`` carries at
|
||||
the same level — the source root at the default budget AND the full
|
||||
one, and the nested ``a`` folder on the full build — the JSON
|
||||
compared whole, so the drill can never paint a node the tree would
|
||||
not have produced."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
for depth in (1, 8):
|
||||
tree = admin_client.get(f"/api/docs/tree?depth={depth}").json()
|
||||
(source_tree,) = tree["sources"]
|
||||
node_root = admin_client.get(
|
||||
"/api/docs/tree/node", params={"source": "Homelab", "depth": depth}
|
||||
)
|
||||
assert node_root.status_code == 200
|
||||
assert node_root.json() == source_tree
|
||||
full = admin_client.get("/api/docs/tree?depth=8").json()
|
||||
a_tree = _find_child(full["sources"][0], "a")
|
||||
node_a = admin_client.get(
|
||||
"/api/docs/tree/node",
|
||||
params={"source": "Homelab", "path": "a", "depth": 8},
|
||||
)
|
||||
assert node_a.status_code == 200
|
||||
assert node_a.json() == a_tree
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
_CATALOGUE_READ_URLS = (
|
||||
"/api/docs",
|
||||
"/api/docs/tree",
|
||||
"/api/docs/tree/node?source=Homelab&path=a",
|
||||
)
|
||||
|
||||
|
||||
def test_catalogue_reads_carry_etag_and_answer_304(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""The ETag matrix on all three catalogue reads (A4): the first GET
|
||||
→ 200 + weak ``ETag`` + ``Cache-Control: private, must-revalidate``;
|
||||
the re-send with ``If-None-Match`` → 304 with an EMPTY body and
|
||||
BOTH headers (RFC 9110 §8.8.3 — a 304 repeats the validator); the
|
||||
strong form of the weak tag, a comma-separated list and ``*`` all
|
||||
revalidate; a wrong tag is a plain 200 carrying the CURRENT tag."""
|
||||
_seed_depth_corpus(db)
|
||||
try:
|
||||
for url in _CATALOGUE_READ_URLS:
|
||||
first = admin_client.get(url)
|
||||
assert first.status_code == 200, url
|
||||
tag = first.headers["etag"]
|
||||
assert tag.startswith('W/"'), url
|
||||
assert first.headers["cache-control"] == "private, must-revalidate", url
|
||||
|
||||
hit = admin_client.get(url, headers={"If-None-Match": tag})
|
||||
assert hit.status_code == 304, url
|
||||
assert hit.content == b"", url
|
||||
assert hit.headers["etag"] == tag, url
|
||||
assert hit.headers["cache-control"] == "private, must-revalidate", url
|
||||
|
||||
strong = tag[2:] # the same tag WITHOUT the weakness marker
|
||||
assert admin_client.get(
|
||||
url, headers={"If-None-Match": strong}
|
||||
).status_code == 304, url
|
||||
assert admin_client.get(
|
||||
url, headers={"If-None-Match": f'W/"stale-tag", {strong}'}
|
||||
).status_code == 304, url
|
||||
assert admin_client.get(url, headers={"If-None-Match": "*"}).status_code == (
|
||||
304
|
||||
), url
|
||||
|
||||
miss = admin_client.get(url, headers={"If-None-Match": 'W/"not-the-tag"'})
|
||||
assert miss.status_code == 200, url
|
||||
assert miss.headers["etag"] == tag, url
|
||||
assert miss.json() == first.json(), url
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def _seed_bust_corpus(db) -> datetime:
|
||||
"""The depth corpus with DETERMINISTIC, distinct ``created_at``
|
||||
stamps (the default seeding's subtree max is insert-time
|
||||
microseconds): ``a/b/c/deep.md`` IS the max of ``a``, so editing its
|
||||
date provably MOVES every answer that carries ``a``'s
|
||||
``updated_at`` — which is what the bust test below asserts."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
dates = {
|
||||
"root.md": base - timedelta(days=30),
|
||||
"a/top.md": base - timedelta(days=20),
|
||||
"a/b/mid.md": base - timedelta(days=10),
|
||||
"a/b/c/deep.md": base - timedelta(days=1), # the subtree max of "a"
|
||||
}
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
for i, (path, created) in enumerate(dates.items()):
|
||||
doc = Document(
|
||||
source="Homelab",
|
||||
path=path,
|
||||
full_path=f"/tmp/Homelab/{path}",
|
||||
title=path,
|
||||
content=f"# {path}\n\nBody.",
|
||||
content_hash=f"{next(_counter):064d}", # unique per row
|
||||
indexed_at=base,
|
||||
created_at=created,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
db.add(
|
||||
Chunk(document_id=doc.id, position=0, content=f"chunk {i}", embedding=[0.01] * 768)
|
||||
)
|
||||
db.add(FolderSummary(source="Homelab", folder_path="a", summary="A desc."))
|
||||
db.commit() # the app's endpoint session reads committed data only
|
||||
return base
|
||||
|
||||
|
||||
def test_etag_busts_on_folder_summary_and_document_date(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""The LOCKED A4 proof: the admin PATCHes mutate the tree WITHOUT
|
||||
bumping ``sources_meta.version`` (asserted around the edits — a
|
||||
version-derived tag would keep answering 304 here). After
|
||||
``PATCH /api/folders/summary`` AND after ``PATCH
|
||||
/api/documents/date`` the tag CHANGES and the conditional GET with
|
||||
the OLD tag answers a fresh 200 carrying the new data."""
|
||||
_seed_bust_corpus(db)
|
||||
try:
|
||||
version_before = db.scalar(select(func.max(SourcesMeta.version)))
|
||||
tree_url = "/api/docs/tree"
|
||||
node_url = "/api/docs/tree/node?source=Homelab&path=a"
|
||||
old_tree = admin_client.get(tree_url).headers["etag"]
|
||||
old_node = admin_client.get(node_url).headers["etag"]
|
||||
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
json={"source": "Homelab", "folder_path": "a", "summary": "Handwritten."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
t = admin_client.get(tree_url, headers={"If-None-Match": old_tree})
|
||||
assert t.status_code == 200 and t.headers["etag"] != old_tree
|
||||
assert _find_child(t.json()["sources"][0], "a")["summary"] == "Handwritten."
|
||||
n = admin_client.get(node_url, headers={"If-None-Match": old_node})
|
||||
assert n.status_code == 200 and n.headers["etag"] != old_node
|
||||
assert n.json()["summary"] == "Handwritten."
|
||||
|
||||
# The date edit moves the subtree max (deep.md is "a"'s max in
|
||||
# this corpus) — the tree, the node AND /api/docs all change.
|
||||
old_tree2 = admin_client.get(tree_url).headers["etag"]
|
||||
old_docs = admin_client.get("/api/docs").headers["etag"]
|
||||
deep_max_before = _find_child(
|
||||
admin_client.get(tree_url).json()["sources"][0], "a"
|
||||
)["updated_at"]
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": "Homelab", "path": "a/b/c/deep.md", "date": "2020-01-01"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
t2 = admin_client.get(tree_url, headers={"If-None-Match": old_tree2})
|
||||
assert t2.status_code == 200 and t2.headers["etag"] != old_tree2
|
||||
deep_max_after = _find_child(t2.json()["sources"][0], "a")["updated_at"]
|
||||
assert deep_max_after != deep_max_before # the body really moved
|
||||
d2 = admin_client.get("/api/docs", headers={"If-None-Match": old_docs})
|
||||
assert d2.status_code == 200 and d2.headers["etag"] != old_docs
|
||||
deep = next(d for d in d2.json()["documents"] if d["path"] == "a/b/c/deep.md")
|
||||
assert deep["created_at"].startswith("2020-01-01")
|
||||
# A4's other half: NEITHER PATCH bumped the KB generation (that
|
||||
# would mark every saved chat stale — phase 53).
|
||||
assert db.scalar(select(func.max(SourcesMeta.version))) == version_before
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
|
||||
def test_gzipped_catalogue_200_carries_etag_and_conditional_304(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""The compression layer and the validator coexist: the gzipped 200
|
||||
of the tree still carries the ``ETag`` (the header survives the
|
||||
encoding), and the conditional request answers a body-less 304 —
|
||||
no ``Content-Encoding`` on a response with no body."""
|
||||
_seed_gzip_corpus(db)
|
||||
try:
|
||||
# depth=8: the whole 50-doc tree stays above the 860-byte floor
|
||||
# (the default depth-1 body would ride under it, identity).
|
||||
r = admin_client.get("/api/docs/tree?depth=8", headers={"Accept-Encoding": "gzip"})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-encoding"] == "gzip"
|
||||
tag = r.headers["etag"]
|
||||
assert tag.startswith('W/"')
|
||||
assert r.headers["cache-control"] == "private, must-revalidate"
|
||||
hit = admin_client.get(
|
||||
"/api/docs/tree?depth=8",
|
||||
headers={"Accept-Encoding": "gzip", "If-None-Match": tag},
|
||||
)
|
||||
assert hit.status_code == 304
|
||||
assert hit.content == b""
|
||||
assert "content-encoding" not in hit.headers
|
||||
assert hit.headers["etag"] == tag
|
||||
assert hit.headers["cache-control"] == "private, must-revalidate"
|
||||
finally:
|
||||
_truncate_tree_tables(db)
|
||||
|
||||
@@ -154,7 +154,10 @@ def test_tree_file_dates_and_derived_subtree_max(admin_client, db) -> None:
|
||||
_seed_doc(db, "Homelab", "a/top.md", "Top", 1, base, C2)
|
||||
_seed_doc(db, "Homelab", "root.md", "Root", 0, base, C0)
|
||||
try:
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
# Phase 167 (task 02): this D9 walk visits ``a`` → ``a/b`` →
|
||||
# ``a/b/c``, so it asks for the WHOLE tree (depth=8); the
|
||||
# endpoint's default (depth=1) is the RAG view's first paint.
|
||||
r = admin_client.get("/api/docs/tree?depth=8")
|
||||
assert r.status_code == 200
|
||||
sources = r.json()["sources"]
|
||||
# Registry order leads; the 0-document registered source lists.
|
||||
@@ -198,6 +201,49 @@ def test_tree_file_dates_and_derived_subtree_max(admin_client, db) -> None:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_tree_truncated_folder_keeps_the_recursive_date_at_default_depth(
|
||||
admin_client, db
|
||||
) -> None:
|
||||
"""Phase 167 (task 02, D2/A2) — the truncation-proof counterpart of
|
||||
the D9 walk above: at the DEFAULT depth (the view's first paint)
|
||||
the deep levels are not sent, but the truncated folder node still
|
||||
carries the phase-106 D9 contract — ``updated_at`` = the subtree's
|
||||
recursive MAX (deep C3) and ``documents`` = the recursive count —
|
||||
alongside ``truncated: true`` and ``children: []`` (the builder's
|
||||
max-date map is what buys this: the payload diet never loses the
|
||||
date column). The server ``totals`` ride the same response.
|
||||
"""
|
||||
_truncate(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/deep.md", "Deep", 1, base, C3)
|
||||
_seed_doc(db, "Homelab", "a/b/shallow.md", "Shallow", 0, base, C1)
|
||||
_seed_doc(db, "Homelab", "a/top.md", "Top", 1, base, C2)
|
||||
_seed_doc(db, "Homelab", "root.md", "Root", 0, base, C0)
|
||||
try:
|
||||
r = admin_client.get("/api/docs/tree") # the DEFAULT depth = 1
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
homelab = _tree_node(body["sources"], "Homelab")
|
||||
# One level only: the folder ``a`` is present but NOT built out.
|
||||
(a,) = [c for c in homelab["children"] if c["kind"] == "folder"]
|
||||
assert (a["path"], a["truncated"], a["children"]) == ("a", True, [])
|
||||
# The truncation-proof fields: recursive count + recursive date.
|
||||
assert a["documents"] == 3
|
||||
assert a["updated_at"] == C3.isoformat() # the DEEP max, unbuilt children
|
||||
# The source node's own max still spans the whole (unsent) subtree.
|
||||
assert homelab["updated_at"] == C3.isoformat()
|
||||
assert "truncated" not in homelab # depth >= 1: never truncated, key omitted
|
||||
# Server totals beside the depth-limited tree (the stat cards).
|
||||
assert body["totals"] == {
|
||||
"documents": 4,
|
||||
"chunks": 2,
|
||||
"last_indexed": base.isoformat(),
|
||||
}
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /api/documents/date (D7) — the admin document-date editor.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Integration: the byte-budget log line on the REAL app stack (phase 167, task 05).
|
||||
|
||||
``tests/unit/test_response_budget.py`` pins the middleware on a bare ASGI
|
||||
app; this file pins what only the real stack can prove —
|
||||
|
||||
* the line describes the REAL ``GET /api/docs/tree``: ``bytes=`` equals the
|
||||
JSON the route produced, ``enc=identity`` for an identity client and
|
||||
``enc=gzip`` through the actual ``SelectiveGZipMiddleware`` (the
|
||||
reading that works because the middleware sits INSIDE the compressor),
|
||||
* a conditional revalidation of that route logs ``status=304 bytes=0`` —
|
||||
the phase-167 win visible in the log, and
|
||||
* the level follows ``log_large_response_bytes`` (DEBUG under it, INFO at
|
||||
or over it, and ``/api/health`` under the 64 KB default), while
|
||||
non-``/api/`` paths (the shell page through the caching + compression
|
||||
layers) write nothing.
|
||||
|
||||
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
import app.core.response_budget as budget_mod
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Chunk, Document
|
||||
|
||||
_LINE_RE = re.compile(
|
||||
r"^api method=(?P<method>[A-Z]+) path=(?P<path>\S+) status=(?P<status>\d+) "
|
||||
r"bytes=(?P<bytes>\d+) enc=(?P<enc>\S+) duration_ms=(?P<ms>\d+)$"
|
||||
)
|
||||
|
||||
_counter = itertools.count()
|
||||
|
||||
|
||||
def _clean(db) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_corpus(db, count: int = 50) -> None:
|
||||
"""``count`` indexed docs — the tree response lands well above the
|
||||
860-byte gzip floor (the corpus that motivates the phase is ~1 k docs)."""
|
||||
_clean(db)
|
||||
base = datetime.now(UTC)
|
||||
for i in range(count):
|
||||
doc = Document(
|
||||
source="budget-corpus",
|
||||
path=f"docs/sub-{i % 5}/file-{i:02d}.md",
|
||||
full_path=f"/tmp/budget-corpus/docs/sub-{i % 5}/file-{i:02d}.md",
|
||||
title=f"Doc {i:02d}",
|
||||
content=f"# Doc {i:02d}\n\nBody of document {i:02d}.",
|
||||
content_hash=f"{next(_counter):064d}",
|
||||
indexed_at=base,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
db.add(
|
||||
Chunk(
|
||||
document_id=doc.id,
|
||||
position=0,
|
||||
content=f"chunk of doc {i:02d}",
|
||||
embedding=[0.01] * 768,
|
||||
)
|
||||
)
|
||||
db.commit() # the endpoint session reads committed data only
|
||||
|
||||
|
||||
def _budget_lines(
|
||||
caplog: pytest.LogCaptureFixture, path: str | None = None
|
||||
) -> list[tuple[int, str]]:
|
||||
"""``(levelno, message)`` for the budget logger, optionally narrowed to
|
||||
one request path (the ``admin_client`` fixture's own login is a real
|
||||
``/api/*`` response too)."""
|
||||
lines = [
|
||||
(record.levelno, record.getMessage())
|
||||
for record in caplog.records
|
||||
if record.name == "app.response_budget"
|
||||
]
|
||||
if path is None:
|
||||
return lines
|
||||
return [(level, message) for level, message in lines if f" path={path} " in message]
|
||||
|
||||
|
||||
def test_tree_response_logs_the_exact_line_for_the_route_bytes(
|
||||
admin_client: TestClient, db, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""``bytes=`` is what the route produced — with an identity client that
|
||||
is exactly the body the caller received."""
|
||||
_seed_corpus(db)
|
||||
try:
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
response = admin_client.get(
|
||||
"/api/docs/tree?depth=8", headers={"Accept-Encoding": "identity"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
lines = _budget_lines(caplog, "/api/docs/tree")
|
||||
assert len(lines) == 1
|
||||
match = _LINE_RE.match(lines[0][1])
|
||||
assert match is not None, lines[0][1]
|
||||
assert match.group("method") == "GET"
|
||||
assert match.group("status") == "200"
|
||||
assert int(match.group("bytes")) == len(response.content)
|
||||
assert match.group("enc") == "identity"
|
||||
assert int(match.group("ms")) >= 0
|
||||
finally:
|
||||
_clean(db)
|
||||
|
||||
|
||||
def test_gzipped_tree_logs_enc_gzip_with_the_uncompressed_bytes(
|
||||
admin_client: TestClient, db, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The D5 reading, end to end: the client received gzip and the log says
|
||||
``enc=gzip`` while ``bytes=`` stays the ROUTE's size — which is why the
|
||||
middleware is wired inside the compression layer."""
|
||||
_seed_corpus(db)
|
||||
try:
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
identity = admin_client.get(
|
||||
"/api/docs/tree?depth=8", headers={"Accept-Encoding": "identity"}
|
||||
)
|
||||
response = admin_client.get(
|
||||
"/api/docs/tree?depth=8", headers={"Accept-Encoding": "gzip"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.content == identity.content
|
||||
|
||||
lines = _budget_lines(caplog, "/api/docs/tree")
|
||||
assert len(lines) == 2
|
||||
identity_line, gzip_line = (
|
||||
_LINE_RE.match(lines[0][1]),
|
||||
_LINE_RE.match(lines[1][1]),
|
||||
)
|
||||
assert identity_line is not None and identity_line.group("enc") == "identity"
|
||||
assert gzip_line is not None and gzip_line.group("enc") == "gzip"
|
||||
# Same route bytes in both lines…
|
||||
assert identity_line.group("bytes") == gzip_line.group("bytes")
|
||||
# …and that is the ROUTE's size, not the compressed transfer.
|
||||
assert int(gzip_line.group("bytes")) == len(identity.content)
|
||||
finally:
|
||||
_clean(db)
|
||||
|
||||
|
||||
def test_conditional_revalidation_logs_zero_bytes(
|
||||
admin_client: TestClient, db, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The phase-167 win is visible, not hidden: the 304 costs one line with
|
||||
``bytes=0``."""
|
||||
_seed_corpus(db)
|
||||
try:
|
||||
fresh = admin_client.get("/api/docs/tree?depth=1")
|
||||
assert fresh.status_code == 200
|
||||
etag = fresh.headers["etag"]
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
revalidated = admin_client.get(
|
||||
"/api/docs/tree?depth=1", headers={"If-None-Match": etag}
|
||||
)
|
||||
assert revalidated.status_code == 304
|
||||
|
||||
lines = _budget_lines(caplog, "/api/docs/tree")
|
||||
assert len(lines) == 1
|
||||
match = _LINE_RE.match(lines[0][1])
|
||||
assert match is not None
|
||||
assert match.group("status") == "304"
|
||||
assert match.group("bytes") == "0"
|
||||
finally:
|
||||
_clean(db)
|
||||
|
||||
|
||||
def test_level_follows_the_configured_budget(
|
||||
admin_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""``/api/health`` sits under the 65536 default → DEBUG; the same
|
||||
response with ``log_large_response_bytes=0`` is INFO (the loud mode). The
|
||||
middleware reads the setting per response, so this is the operator's
|
||||
runtime knob, not a boot-time one."""
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
assert admin_client.get("/api/health").status_code == 200
|
||||
assert [level for level, _ in _budget_lines(caplog, "/api/health")] == [logging.DEBUG]
|
||||
|
||||
monkeypatch.setattr(
|
||||
budget_mod,
|
||||
"get_settings",
|
||||
lambda: Settings(_env_file=None, log_large_response_bytes=0), # pyright: ignore[reportCallIssue]
|
||||
)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
assert admin_client.get("/api/health").status_code == 200
|
||||
assert [level for level, _ in _budget_lines(caplog, "/api/health")] == [logging.INFO]
|
||||
|
||||
|
||||
def test_html_page_writes_no_budget_line(
|
||||
admin_client: TestClient, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The shell page rides the caching rewrite AND the compression layer
|
||||
and still writes nothing — the budget measures ``/api/*`` only."""
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
response = admin_client.get("/sources.html", headers={"Accept-Encoding": "gzip"})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
|
||||
assert not any(" path=/sources.html " in message for _, message in _budget_lines(caplog))
|
||||
|
||||
|
||||
def test_anonymous_403_is_measured_like_any_other_response(
|
||||
client: TestClient, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The gate is a response too — an unauthenticated catalogue read logs
|
||||
its status (and the byte line is what makes a chatty 403 visible)."""
|
||||
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
|
||||
assert client.get("/api/docs").status_code == 403
|
||||
|
||||
lines = _budget_lines(caplog, "/api/docs")
|
||||
assert len(lines) == 1
|
||||
match: Any = _LINE_RE.match(lines[0][1])
|
||||
assert match is not None
|
||||
assert match.group("status") == "403"
|
||||
|
||||
|
||||
def test_the_module_level_app_carries_the_middleware() -> None:
|
||||
"""Smoke: the wiring pin in ``tests/unit/test_response_budget.py`` covers
|
||||
the ORDER on a fresh ``create_app()``; this proves the module-level ``app``
|
||||
the whole suite (and the dev server) runs against has the layer too."""
|
||||
names = [getattr(mw.cls, "__name__", "") for mw in fastapi_app.user_middleware]
|
||||
assert "ResponseBudgetMiddleware" in names
|
||||
Reference in New Issue
Block a user