Files
ducoterra 2ac37455f0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 14s
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.
2026-10-08 21:23:49 -04:00

498 lines
20 KiB
Python

"""Unit tests: ``app.core.compression.SelectiveGZipMiddleware`` (phase 167, D1, LOCKED A1).
Pins the three contracts of the selective-gzip layer on a tiny ASGI app —
* a ~50 KB JSON route compresses (``Content-Encoding: gzip``, the body
``gzip.decompress``es to the original, ``Vary`` mentions
``accept-encoding``), an ``Accept-Encoding: identity`` client gets
identity bytes, a ~50-byte route stays under the floor, and a response
that already set ``content-encoding`` is forwarded byte-identical (never
double-encoded — the fronting-nginx contract);
* the EXACT-path skip: a route named ``/api/chat`` is NOT compressed and
streams frame-by-frame (the raw-ASGI interleaving proof: every frame
reaches the client's ``send`` BEFORE the app yields the next one — a
buffering middleware fails this deterministically — plus the
``list(stream)`` client idiom the existing SSE tests use), while
``/api/chat-images/x`` shares the prefix and MUST compress;
* non-HTTP scopes (``lifespan``/``websocket``) pass straight through.
Plus the config kill switch: ``compress_min_bytes=0`` → ``create_app``
adds no gzip layer at all; ``-1`` → the validator refuses startup naming
the field; the middleware sits INSIDE ``SecurityHeadersMiddleware`` (A20)
and OUTSIDE ``CachingMiddleware``.
"""
from __future__ import annotations
import asyncio
import gzip
import json
from typing import Any
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from starlette.applications import Starlette
from starlette.middleware.gzip import GZipMiddleware
from starlette.requests import Request
from starlette.responses import Response, StreamingResponse
from starlette.routing import Route
from starlette.types import Message, Receive, Scope, Send
import app.main as main_mod
from app.config import Settings
from app.core.compression import DEFAULT_SKIP_PATHS, SelectiveGZipMiddleware
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (the test_config pattern)."""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
# ~50 KB of highly compressible JSON — the /api/docs/tree shape class.
_BIG_BODY = json.dumps(
{
"items": [
{"n": i, "path": f"source/docs/file-{i}.md", "title": f"Doc {i}"}
for i in range(1000)
]
}
).encode()
# ~50 bytes — comfortably under the 860-byte floor (compression would inflate).
_SMALL_BODY = json.dumps({"ok": True, "note": "intentionally tiny body"}).encode("utf-8")
# Five SSE frames (~2 KB total — ABOVE the floor, so a missing skip cannot
# hide behind the minimum_size exemption).
_SSE_FRAMES = [
f"event: thinking\ndata: {'x' * 400}\n\n".encode() for _ in range(5)
]
_PRE_ENCODED_BODY = gzip.compress(_BIG_BODY)
def _inner_app() -> Starlette:
async def big(request: Request) -> Response:
return Response(_BIG_BODY, media_type="application/json")
async def small(request: Request) -> Response:
return Response(_SMALL_BODY, media_type="application/json")
async def chat(request: Request) -> StreamingResponse:
async def frames():
for frame in _SSE_FRAMES:
yield frame
return StreamingResponse(frames(), media_type="text/event-stream")
async def chat_images(request: Request) -> Response:
# Shares the /api/chat PREFIX but is a different endpoint — the
# skip must be EXACT, so this one compresses.
return Response(_BIG_BODY, media_type="application/json")
async def pre_encoded(request: Request) -> Response:
# A body that arrived already encoded (e.g. from a future CDN
# layer): the responder skips it, never double-encodes.
return Response(
_PRE_ENCODED_BODY,
media_type="application/json",
headers={"Content-Encoding": "gzip"},
)
return Starlette(
routes=[
Route("/big", big),
Route("/small", small),
Route("/api/chat", chat, methods=["POST"]),
Route("/api/chat-images/x", chat_images),
Route("/pre-encoded", pre_encoded),
]
)
def _client(minimum_size: int = 860) -> TestClient:
return TestClient(
SelectiveGZipMiddleware(_inner_app(), minimum_size=minimum_size)
)
# ---------------------------------------------------------------------------
# The gzip path (Accept-Encoding negotiation, the floor, the pre-encoded skip)
# ---------------------------------------------------------------------------
def test_large_json_compresses_decompresses_and_sets_vary() -> None:
client = _client()
response = client.get("/big", headers={"Accept-Encoding": "gzip"})
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert "accept-encoding" in response.headers.get("vary", "").lower()
# httpx transparently decodes: the documented JSON arrives whole.
assert response.content == _BIG_BODY
assert len(response.content) > 860 # the seed really is above the floor
# The RAW wire bytes are a real gzip stream of the original body.
with client.stream("GET", "/big", headers={"Accept-Encoding": "gzip"}) as streamed:
raw = b"".join(list(streamed.iter_raw()))
assert gzip.decompress(raw) == _BIG_BODY
assert len(raw) < len(_BIG_BODY) # ~8-12x on this JSON; order of magnitude suffices
def test_identity_client_is_not_compressed() -> None:
client = _client()
response = client.get("/big", headers={"Accept-Encoding": "identity"})
assert response.status_code == 200
assert "content-encoding" not in response.headers
assert response.content == _BIG_BODY
def test_small_response_stays_under_the_floor() -> None:
client = _client()
response = client.get("/small", headers={"Accept-Encoding": "gzip"})
assert response.status_code == 200
assert "content-encoding" not in response.headers
assert response.content == _SMALL_BODY
def test_pre_encoded_body_is_never_double_encoded() -> None:
client = _client()
with client.stream("GET", "/pre-encoded", headers={"Accept-Encoding": "gzip"}) as streamed:
assert streamed.headers["content-encoding"] == "gzip"
raw = b"".join(list(streamed.iter_raw()))
# Byte-identical passthrough of the already-encoded body (Starlette's
# "skip when content-encoding is set" rule — the nginx coexistence pin).
assert raw == _PRE_ENCODED_BODY
assert gzip.decompress(raw) == _BIG_BODY
# ---------------------------------------------------------------------------
# The EXACT-path skip (A15: the SSE stream is never compressed, never buffered)
# ---------------------------------------------------------------------------
def test_default_skip_paths_constant() -> None:
assert DEFAULT_SKIP_PATHS == ("/api/chat",)
def test_chat_route_is_not_compressed_and_streams_whole() -> None:
"""The client-level pin (the ``list(stream)`` idiom the SSE tests use):
a route named /api/chat carries NO Content-Encoding and its raw bytes
are exactly the SSE frames, concatenated, above the size floor."""
client = _client()
with client.stream(
"POST", "/api/chat", json={"message": "hi"}, headers={"Accept-Encoding": "gzip"}
) as streamed:
assert streamed.status_code == 200
assert "content-encoding" not in streamed.headers
chunks = list(streamed.iter_raw())
assert b"".join(chunks) == b"".join(_SSE_FRAMES)
assert sum(len(c) for c in chunks) > 860 # the skip beats the floor, not size
def test_skipped_stream_arrives_frame_by_frame_uncompressed() -> None:
"""The frame-by-frame proof (deterministic, no timing): every frame
reaches the client's ``send`` BEFORE the app produces the next one.
A buffering middleware (BaseHTTPMiddleware, or a gzip pass) yields
all ``app:*`` events first — this strict interleaving fails then."""
events: list[str] = [] # shared: the app logs a yield, send logs delivery
sent: list[Message] = []
async def sse_app(scope: Scope, receive: Receive, send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"text/event-stream")],
}
)
for i, frame in enumerate(_SSE_FRAMES):
events.append(f"app:frame-{i}")
await send({"type": "http.response.body", "body": frame, "more_body": True})
events.append("app:final")
await send({"type": "http.response.body", "body": b"", "more_body": False})
async def recording_send(message: Message) -> None:
events.append(f"client:{message['type']}")
sent.append(message)
async def receive() -> Message:
return {"type": "http.disconnect"}
middleware = SelectiveGZipMiddleware(sse_app, minimum_size=860)
asyncio.run(
middleware(
{"type": "http", "path": "/api/chat", "headers": [(b"accept-encoding", b"gzip")]},
receive,
recording_send,
)
)
expected = ["client:http.response.start"]
for i in range(len(_SSE_FRAMES)):
expected += [f"app:frame-{i}", "client:http.response.body"]
expected += ["app:final", "client:http.response.body"]
assert events == expected
start = sent[0]
headers = {k.decode().lower() for k, _ in start["headers"]} # pyright: ignore[reportGeneralTypeIssues]
assert "content-encoding" not in headers
body = b"".join(m.get("body", b"") for m in sent[1:]) # pyright: ignore[reportGeneralTypeIssues]
assert body == b"".join(_SSE_FRAMES)
def _raw_harness(
*,
path: str,
app: Any,
) -> list[Message]:
"""Drive the middleware at the raw ASGI level; return the exact
messages that reached the client's ``send``."""
sent: list[Message] = []
async def recording_send(message: Message) -> None:
sent.append(message)
async def receive() -> Message:
return {"type": "http.disconnect"}
scope: Scope = {
"type": "http",
"path": path,
"headers": [(b"accept-encoding", b"gzip")],
}
middleware = SelectiveGZipMiddleware(app, minimum_size=860)
asyncio.run(middleware(scope, receive, recording_send))
return sent
def test_same_stream_at_a_non_skipped_path_goes_through_gzip() -> None:
"""The skip is PATH-driven: the identical streaming app mounted at a
non-skipped path is compressed by the composed GZipMiddleware."""
async def json_stream_app(scope: Scope, receive: Receive, send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"application/json")],
}
)
for i, frame in enumerate(_SSE_FRAMES):
await send(
{"type": "http.response.body", "body": frame, "more_body": i < len(_SSE_FRAMES) - 1}
)
sent = _raw_harness(path="/api/other-stream", app=json_stream_app)
start_headers = {
k.decode().lower(): v.decode() for k, v in sent[0]["headers"] # pyright: ignore[reportGeneralTypeIssues]
}
assert start_headers.get("content-encoding") == "gzip"
raw = b"".join(m.get("body", b"") for m in sent[1:]) # pyright: ignore[reportGeneralTypeIssues]
assert gzip.decompress(raw) == b"".join(_SSE_FRAMES)
def test_reframed_small_body_stays_identity_under_the_floor() -> None:
"""The CachingMiddleware seam (starlette 1.x ``BaseHTTPMiddleware``
re-frames every response it passes up: chunk with ``more_body=True``
+ empty terminator): a body whose ``content-length`` is below the
floor is re-buffered into ONE buffered message so Starlette's own
small-response exemption fires — identity bytes, no ``Vary`` append."""
payload = b'{"status":"ok","version":"0.1.0"}' # 33 bytes — the /api/health class
async def reframed_app(scope: Scope, receive: Receive, send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
],
}
)
await send({"type": "http.response.body", "body": payload, "more_body": True})
await send({"type": "http.response.body", "body": b"", "more_body": False})
sent = _raw_harness(path="/api/health", app=reframed_app)
start_headers = {
k.decode().lower(): v.decode() for k, v in sent[0]["headers"] # pyright: ignore[reportGeneralTypeIssues]
}
assert "content-encoding" not in start_headers
assert "vary" not in start_headers # the exemption adds nothing
body = b"".join(m.get("body", b"") for m in sent[1:]) # pyright: ignore[reportGeneralTypeIssues]
assert body == payload
def test_lying_content_length_that_outgrows_the_floor_compresses() -> None:
"""A body that outgrows the floor despite a small ``content-length``
stops the hold and streams through the normal compressing path — the
buffer is bounded by the floor and correctness never depends on the
header being honest."""
async def lying_app(scope: Scope, receive: Receive, send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", b"5"), # a lie
],
}
)
await send({"type": "http.response.body", "body": _BIG_BODY, "more_body": True})
await send({"type": "http.response.body", "body": b"", "more_body": False})
sent = _raw_harness(path="/api/lias", app=lying_app)
start_headers = {
k.decode().lower(): v.decode() for k, v in sent[0]["headers"] # pyright: ignore[reportGeneralTypeIssues]
}
assert start_headers.get("content-encoding") == "gzip"
raw = b"".join(m.get("body", b"") for m in sent[1:]) # pyright: ignore[reportGeneralTypeIssues]
assert gzip.decompress(raw) == _BIG_BODY
def test_non_body_message_while_holding_flushes_untouched() -> None:
"""``http.response.pathsend`` (or anything unexpected) arriving while
the hold is active: the held messages and the message itself are
forwarded byte-for-byte — the hold never swallows a frame."""
async def pathsend_app(scope: Scope, receive: Receive, send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"text/plain"),
(b"content-length", b"4"),
],
}
)
await send({"type": "http.response.pathsend", "path": "/tmp/file"})
sent = _raw_harness(path="/assets/file", app=pathsend_app)
assert [m["type"] for m in sent] == ["http.response.start", "http.response.pathsend"]
start_headers = {k.decode().lower() for k, _ in sent[0]["headers"]} # pyright: ignore[reportGeneralTypeIssues]
assert "content-encoding" not in start_headers
def test_chat_images_prefix_still_compresses() -> None:
"""The exact-match pin: /api/chat-images/x shares the /api/chat
prefix and MUST go through the gzip path."""
client = _client()
response = client.get("/api/chat-images/x", headers={"Accept-Encoding": "gzip"})
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.content == _BIG_BODY
# ---------------------------------------------------------------------------
# Non-HTTP scopes pass straight through
# ---------------------------------------------------------------------------
def test_non_http_scopes_pass_through_untouched() -> None:
seen: list[str] = []
async def probe_app(scope: Scope, receive: Receive, send: Send) -> None:
seen.append(scope["type"])
async def receive() -> Message:
return {"type": "http.disconnect"}
async def send(message: Message) -> None: # pragma: no cover - app sends nothing
raise AssertionError("no ASGI messages expected")
middleware = SelectiveGZipMiddleware(probe_app, minimum_size=860)
for scope_type in ("lifespan", "websocket"):
asyncio.run(
middleware({"type": scope_type, "path": "/api/anything"}, receive, send)
)
assert seen == ["lifespan", "websocket"]
def test_composes_starlette_gzip_with_minimum_size() -> None:
"""The middleware reuses Starlette's GZipMiddleware, it does not fork
the compression math (D1)."""
async def app(scope: Scope, receive: Receive, send: Send) -> None: ...
middleware = SelectiveGZipMiddleware(app, minimum_size=1234)
assert isinstance(middleware.gzip, GZipMiddleware)
assert middleware.gzip.minimum_size == 1234
assert middleware.skip_paths == DEFAULT_SKIP_PATHS
# ---------------------------------------------------------------------------
# Config: the BOR_COMPRESS_MIN_BYTES kill switch and the loud negative
# ---------------------------------------------------------------------------
def _middleware_names(app: Any) -> list[str]:
"""Middleware class names, outermost first (``user_middleware`` order)."""
return [getattr(mw.cls, "__name__", "") for mw in app.user_middleware]
def test_compress_min_bytes_defaults_to_the_nginx_floor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("BOR_COMPRESS_MIN_BYTES", raising=False)
assert _settings().compress_min_bytes == 860
def test_compress_min_bytes_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BOR_COMPRESS_MIN_BYTES", "2048")
assert _settings().compress_min_bytes == 2048
monkeypatch.setenv("BOR_COMPRESS_MIN_BYTES", "0") # the kill switch is legal
assert _settings().compress_min_bytes == 0
def test_compress_min_bytes_negative_refuses_startup_naming_the_field() -> None:
with pytest.raises(ValidationError, match="compress_min_bytes must be >= 0"):
_settings(compress_min_bytes=-1)
def test_create_app_wires_gzip_inside_security_headers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A20 holds: SecurityHeaders stays outermost (index 0 — starlette's
last add_middleware is the outermost layer), gzip sits between it and
the caching layer, so an already-rewritten HTML page compresses and
the /api/* caching pass-through happens before compression.
Phase 167 task 05 adds ``ResponseBudgetMiddleware`` between gzip and
caching — INSIDE the compressor on purpose, so its ``bytes=`` counts the
body the ROUTE produced while its ``enc=`` still reads the encoding the
compressor put on the wire (see tests/unit/test_response_budget.py)."""
monkeypatch.setattr(main_mod.settings, "compress_min_bytes", 860)
app2 = main_mod.create_app()
assert _middleware_names(app2) == [
"SecurityHeadersMiddleware",
"SelectiveGZipMiddleware",
"ResponseBudgetMiddleware",
"CachingMiddleware",
"SessionMiddleware",
]
# The app still serves.
assert TestClient(app2).get("/api/health").status_code == 200
def test_create_app_adds_no_gzip_layer_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""BOR_COMPRESS_MIN_BYTES=0 removes the middleware entirely (the kill
switch): the gzip class is absent from app.user_middleware."""
monkeypatch.setattr(main_mod.settings, "compress_min_bytes", 0)
app2 = main_mod.create_app()
names = _middleware_names(app2)
assert "SelectiveGZipMiddleware" not in names
assert names == [
"SecurityHeadersMiddleware",
# Phase 167 task 05: the byte-budget line is not optional.
"ResponseBudgetMiddleware",
"CachingMiddleware",
"SessionMiddleware",
]