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.
111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
"""Unit tests: ``app/core/http_cache.py`` (phase 167, task 03).
|
|
|
|
The three pure helpers behind the catalogue reads' revalidation
|
|
(LOCKED A4): the weak content-hash tag (shape, stability, the bytes-vs-
|
|
str equality), the tolerant ``If-None-Match`` evaluator (``None`` /
|
|
empty / ``*`` / single / comma-list / weak↔strong both directions /
|
|
whitespace / the prefix-is-not-a-match pin), and the ONE header pair.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from app.core.http_cache import (
|
|
REVALIDATE_CACHE_CONTROL,
|
|
etag_matches,
|
|
revalidate_headers,
|
|
weak_etag,
|
|
)
|
|
|
|
_TAG_RE = re.compile(r'W/"[0-9a-f]{32}"')
|
|
|
|
|
|
def test_weak_etag_shape_is_weak_32_hex() -> None:
|
|
"""``W/"<sha256 hexdigest[:32]>"`` — the prefix, the 32-hex-char body,
|
|
the total 36-char length (the file's truncation convention)."""
|
|
tag = weak_etag("hello")
|
|
assert _TAG_RE.fullmatch(tag), tag
|
|
assert len(tag) == 36
|
|
assert tag.startswith('W/"') and tag.endswith('"')
|
|
|
|
|
|
def test_weak_etag_is_deterministic_and_content_sensitive() -> None:
|
|
assert weak_etag("same body") == weak_etag("same body")
|
|
assert weak_etag("same body") != weak_etag("same body!")
|
|
assert weak_etag("") != weak_etag(" ")
|
|
|
|
|
|
def test_weak_etag_bytes_and_str_forms_agree() -> None:
|
|
"""The hash is over the UTF-8 bytes either way — the endpoint may
|
|
hash the ``str`` and serve its encoding without diverging."""
|
|
text = '{"tree": "héllo — ünïcode"}'
|
|
assert weak_etag(text) == weak_etag(text.encode("utf-8"))
|
|
|
|
|
|
def test_weak_etag_truncation_matches_the_sha256_prefix() -> None:
|
|
import hashlib
|
|
|
|
body = "exact served bytes"
|
|
expected = hashlib.sha256(body.encode("utf-8")).hexdigest()[:32]
|
|
assert weak_etag(body) == f'W/"{expected}"'
|
|
|
|
|
|
def test_etag_matches_none_and_empty_never_match() -> None:
|
|
"""No conditional header (or an empty/whitespace-only one) is an
|
|
unconditional GET → the route must answer 200."""
|
|
assert etag_matches(None, 'W/"x"') is False
|
|
assert etag_matches("", 'W/"x"') is False
|
|
assert etag_matches(" ", 'W/"x"') is False
|
|
|
|
|
|
def test_etag_matches_star() -> None:
|
|
"""RFC 9110 §13.3.3: ``*`` matches anything the server holds."""
|
|
assert etag_matches("*", 'W/"x"') is True
|
|
assert etag_matches(" * ", 'W/"anything"') is True
|
|
|
|
|
|
def test_etag_matches_single_candidate() -> None:
|
|
assert etag_matches('W/"x"', 'W/"x"') is True
|
|
assert etag_matches('W/"x"', 'W/"y"') is False
|
|
|
|
|
|
def test_etag_matches_comma_separated_list() -> None:
|
|
header = 'W/"a", W/"b", W/"c"'
|
|
assert etag_matches(header, 'W/"a"') is True
|
|
assert etag_matches(header, 'W/"c"') is True
|
|
assert etag_matches(header, 'W/"d"') is False
|
|
# no space after the comma — a real client sends both shapes
|
|
assert etag_matches('W/"a",W/"b"', 'W/"b"') is True
|
|
|
|
|
|
def test_etag_matches_weak_strong_equivalence_both_directions() -> None:
|
|
"""``W/"x"`` matches ``"x"`` whichever side carries the weakness
|
|
marker (the stored form must not decide the outcome)."""
|
|
assert etag_matches('"x"', 'W/"x"') is True # client strong, ours weak
|
|
assert etag_matches('W/"x"', '"x"') is True # client weak, ours strong
|
|
|
|
|
|
def test_etag_matches_is_whitespace_tolerant() -> None:
|
|
assert etag_matches(' W/"x" ', 'W/"x"') is True
|
|
assert etag_matches('W/"a" , "x" ', 'W/"x"') is True
|
|
assert etag_matches('W/ "x"', 'W/"x"') is True
|
|
|
|
|
|
def test_etag_matches_prefix_is_not_a_match() -> None:
|
|
"""A tag that merely SHARES a prefix with ours is a mismatch — the
|
|
comparison is the full 32-hex-char digest, not ``startswith``."""
|
|
ours = "abcdef0123456789abcdef0123456789"
|
|
shorter = ours[:-1]
|
|
assert etag_matches(f'W/"{shorter}"', f'W/"{ours}"') is False
|
|
assert etag_matches(f'"{shorter}"', f'W/"{ours}"') is False
|
|
|
|
|
|
def test_revalidate_headers_is_the_one_pair() -> None:
|
|
"""The 200 AND the 304 carry exactly this pair (A19's read-only API
|
|
arm: private + must-revalidate beside the validator)."""
|
|
assert revalidate_headers('W/"x"') == {
|
|
"ETag": 'W/"x"',
|
|
"Cache-Control": "private, must-revalidate",
|
|
}
|
|
assert REVALIDATE_CACHE_CONTROL == "private, must-revalidate"
|