perf(ui): cache busting — HTML no-cache + versioned asset URLs (?v=) with immutable 1y asset caching
Phase 33 (story: .agent/user_stories/cache-busting.md). - app/core/caching.py: asset_version() — git short SHA (a commit is a deploy), stable content-hash fallback for non-git checkouts, "dev" for a missing static dir; computed once per process. CachingMiddleware — the five HTML pages revalidate (no-cache) with ?v=<token> asset refs rewritten in flight; /assets/* is public, max-age=31536000, immutable; everything else (all /api/*, the SSE chat stream in particular) passes through byte-identical. - tests/e2e/test_cache_busting.py: fresh-Chromium wire assertions — document no-cache, versioned CSS/JS request URLs sharing one token, immutable asset headers, /api/health baseline headers, SSE chat to done (mock LLM). - README 'Caching / deploys' section + story file. Also fixed two prod-image defects surfaced by this phase's podman smoke (the full app would not boot): - Containerfile: ship the scripts/ package — app/api/sync.py (phase 32) imports scripts.git_sync / scripts.import_docs at module level, so the container crashed on boot (ModuleNotFoundError: No module named 'scripts'). - compose.yaml: pass BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET through to the app service (:- defaults keep 'podman compose up -d db' working; the app's own fail-loud gate still names missing admin auth). Smoke: podman compose --profile prod up -d on a fresh image + a fresh Chromium profile — /, /sources.html and /login.html all served Cache-Control: no-cache; all 8 asset requests versioned with one shared token (content-hash fallback inside the image — no .git there); /assets/* immutable for a year.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Phase 33 E2E (Playwright): cache busting — what the browser actually
|
||||
receives and requests.
|
||||
|
||||
Story: ``.agent/user_stories/cache-busting.md``
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_cache_busting.py -v --no-cov
|
||||
|
||||
The assertions are against the wire: the HTML document responses carry
|
||||
``Cache-Control: no-cache``; every asset request URL the browser actually
|
||||
makes carries ``?v=<token>`` (one token per process — the git short SHA of
|
||||
this checkout, i.e. the deploy); the asset responses are immutable for a
|
||||
year; and the API — the SSE chat stream in particular — is untouched. The
|
||||
mock LLM keeps the SSE check deterministic (no live aipi).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Page
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
CHAT_QUESTION = "How is my Kubernetes cluster set up?"
|
||||
|
||||
|
||||
def _expected_token() -> str:
|
||||
"""The token the app process appends to its asset URLs.
|
||||
|
||||
Computed exactly the way the app does (``asset_version`` over the same
|
||||
static dir): the git short SHA of this checkout in a git repo (a commit
|
||||
is a deploy), so the browser's asset requests must carry it.
|
||||
"""
|
||||
from app.core.caching import asset_version
|
||||
|
||||
return asset_version(str(REPO / "frontend"))
|
||||
|
||||
|
||||
def _version_token(url: str) -> str:
|
||||
"""Extract the ``?v=`` token from a versioned asset URL (asserts one)."""
|
||||
assert "?v=" in url, f"asset request is not versioned: {url}"
|
||||
return url.rsplit("?v=", 1)[1]
|
||||
|
||||
|
||||
def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
|
||||
"""Minimal SSE chat request (same pattern as ``test_chat_rag.py``):
|
||||
POST /api/chat and collect the ``data:`` frames until the stream ends."""
|
||||
frames: list[dict[str, Any]] = []
|
||||
with httpx.stream(
|
||||
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=60.0
|
||||
) as r:
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/event-stream")
|
||||
buf = ""
|
||||
for part in r.iter_text():
|
||||
buf += part
|
||||
while "\n\n" in buf:
|
||||
frame, buf = buf.split("\n\n", 1)
|
||||
if frame.strip().startswith("data:"):
|
||||
frames.append(
|
||||
json.loads(frame.strip().removeprefix("data:").strip())
|
||||
)
|
||||
return frames
|
||||
|
||||
|
||||
def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None:
|
||||
"""`/`: the document revalidates (no-cache); the CSS/JS request URLs
|
||||
the browser actually makes carry the process token; the asset
|
||||
responses are immutable for a year; the served HTML carries no
|
||||
unversioned asset references."""
|
||||
token = _expected_token()
|
||||
assert token, "the version token must be non-empty"
|
||||
|
||||
with (
|
||||
page.expect_response(lambda r: "/assets/styles.css" in r.url) as css_info,
|
||||
page.expect_response(lambda r: "/assets/app.js" in r.url) as js_info,
|
||||
):
|
||||
doc = page.goto(app_url)
|
||||
|
||||
# The document: always revalidated, never served from cache unchecked.
|
||||
assert doc is not None
|
||||
assert doc.headers["cache-control"] == "no-cache"
|
||||
|
||||
# CSS: versioned request URL + immutable-for-a-year response.
|
||||
css = css_info.value
|
||||
assert _version_token(css.url) == token
|
||||
css_cc = css.headers["cache-control"]
|
||||
assert "immutable" in css_cc
|
||||
assert "max-age=31536000" in css_cc
|
||||
|
||||
# JS: the SAME token (one per process — the URL identifies the
|
||||
# content, which is what makes the 1-year cache safe).
|
||||
assert _version_token(js_info.value.url) == _version_token(css.url)
|
||||
|
||||
# The served HTML carries the versioned reference and no unversioned
|
||||
# one (the "sticky" reference is gone from the page the browser sees).
|
||||
html = page.content()
|
||||
assert f'href="/assets/styles.css?v={token}"' in html
|
||||
assert '/assets/styles.css"' not in html
|
||||
|
||||
|
||||
def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
|
||||
"""/sources.html and /login.html: each document revalidates, and both
|
||||
pages' stylesheet requests carry the same process token."""
|
||||
token = _expected_token()
|
||||
assert token
|
||||
|
||||
def navigate(path: str) -> str:
|
||||
with page.expect_response(
|
||||
lambda r: "/assets/styles.css" in r.url
|
||||
) as css_info:
|
||||
doc = page.goto(f"{app_url}{path}")
|
||||
assert doc is not None
|
||||
assert doc.headers["cache-control"] == "no-cache"
|
||||
return _version_token(css_info.value.url)
|
||||
|
||||
sources_token = navigate("/sources.html")
|
||||
login_token = navigate("/login.html")
|
||||
assert sources_token == login_token == token
|
||||
|
||||
|
||||
def test_api_responses_unaffected(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""`/api/*` passes through untouched: no injected Cache-Control on the
|
||||
health endpoint, and the SSE chat stream still streams to done."""
|
||||
r = page.request.get(f"{app_url}/api/health")
|
||||
assert r.status == 200
|
||||
# Baseline (pre-middleware) behavior: FastAPI's JSON responses ship no
|
||||
# Cache-Control header — the middleware must not inject one.
|
||||
assert "cache-control" not in r.headers
|
||||
|
||||
# The SSE contract (PLAN §4) survives the middleware: deltas, then a
|
||||
# final done — the stream is neither read nor rewritten by it.
|
||||
frames = _stream_chat_frames(app_url, CHAT_QUESTION)
|
||||
assert frames, "the SSE stream must deliver events"
|
||||
assert any(f["type"] == "delta" for f in frames), "answer must be streamed"
|
||||
assert frames[-1]["type"] == "done", "the stream must complete with done"
|
||||
@@ -71,6 +71,72 @@ def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> Non
|
||||
assert 'href="https://' not in r.text
|
||||
|
||||
|
||||
# Phase 33 (cache busting): the five HTML pages revalidate (no-cache) with
|
||||
# ?v=<token> asset refs; /assets/* is immutable for a year; /api/* is
|
||||
# untouched. The token itself is unit-tested in tests/unit/test_caching.py.
|
||||
|
||||
|
||||
def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
|
||||
"""GET / — always revalidated, and the stylesheet reference carries
|
||||
the process version token (non-empty, matching asset_version())."""
|
||||
from app.core.caching import asset_version
|
||||
|
||||
token = asset_version()
|
||||
assert token # non-empty in every supported environment
|
||||
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert f'href="/assets/styles.css?v={token}"' in r.text
|
||||
# The unversioned reference is gone from the served body.
|
||||
assert 'href="/assets/styles.css">' not in r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/sources.html", "/document.html", "/login.html", "/tuning.html"],
|
||||
)
|
||||
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
"""Each of the other four pages revalidates and carries at least one
|
||||
versioned asset reference."""
|
||||
from app.core.caching import asset_version
|
||||
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert f"?v={asset_version()}" in r.text
|
||||
|
||||
|
||||
def test_index_html_variant_no_cache_versioned(client) -> None:
|
||||
"""/index.html is the same page as / — same caching treatment."""
|
||||
from app.core.caching import asset_version
|
||||
|
||||
r = client.get("/index.html")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert f"?v={asset_version()}" in r.text
|
||||
|
||||
|
||||
def test_assets_served_immutable_for_a_year(client) -> None:
|
||||
r = client.get("/assets/styles.css")
|
||||
assert r.status_code == 200
|
||||
cc = r.headers["cache-control"]
|
||||
assert "public" in cc
|
||||
assert "max-age=31536000" in cc
|
||||
assert "immutable" in cc
|
||||
# The asset body is untouched (header-only middleware).
|
||||
assert client.get("/assets/app.js?v=whichever").status_code == 200
|
||||
|
||||
|
||||
def test_api_health_gets_no_cache_control_injected(client) -> None:
|
||||
"""Baseline (pre-middleware) behavior for /api/*: FastAPI's JSON
|
||||
responses ship no Cache-Control header — the middleware must not
|
||||
inject one."""
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert "cache-control" not in r.headers
|
||||
|
||||
|
||||
def test_styles_and_js_served(client) -> None:
|
||||
assert client.get("/assets/styles.css").status_code == 200
|
||||
assert client.get("/assets/app.js").status_code == 200
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Unit tests: the frontend asset version token (phase 33, app/core/caching.py).
|
||||
|
||||
Covers the four token paths:
|
||||
* git repo -> ``git rev-parse --short HEAD`` (stable, cached).
|
||||
* fallback -> 12-hex content hash; stable for an unchanged tree, flips on
|
||||
a size or mtime change once the per-process cache is cleared.
|
||||
* failure -> a ``.git`` present but git broken (missing / timeout /
|
||||
non-zero exit) falls back to the content hash without raising.
|
||||
* empty -> a missing or empty static dir yields ``"dev"``.
|
||||
|
||||
Plus the asset-reference rewrite (``rewrite_asset_refs``) and the
|
||||
CachingMiddleware fallback branches (task 02).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.core.caching as caching
|
||||
from app.config import Settings
|
||||
from app.core.caching import asset_version, rewrite_asset_refs
|
||||
|
||||
TOKEN = "abc123"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_token_cache() -> Iterator[None]:
|
||||
# The token is lru_cached per process — every case starts from a clean
|
||||
# slate and never leaks its entry into the next case.
|
||||
asset_version.cache_clear()
|
||||
yield
|
||||
asset_version.cache_clear()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> None:
|
||||
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
|
||||
|
||||
|
||||
def _make_git_repo(base: Path) -> tuple[Path, str]:
|
||||
"""A repo with a committed ``frontend/`` file; returns (frontend, short SHA)."""
|
||||
repo = base / "proj"
|
||||
frontend = repo / "frontend"
|
||||
frontend.mkdir(parents=True)
|
||||
(frontend / "styles.css").write_text("body { margin: 0 }\n")
|
||||
_git(repo, "init", "-q")
|
||||
_git(repo, "add", "frontend")
|
||||
_git(
|
||||
repo,
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-q",
|
||||
"-m",
|
||||
"init",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "--short", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return frontend, proc.stdout.strip()
|
||||
|
||||
|
||||
def _make_plain_frontend(tmp_path: Path) -> Path:
|
||||
frontend = tmp_path / "frontend"
|
||||
frontend.mkdir()
|
||||
(frontend / "app.js").write_text("console.log('hi')\n")
|
||||
(frontend / "styles.css").write_text("body { margin: 0 }\n")
|
||||
return frontend
|
||||
|
||||
|
||||
def test_git_repo_token_matches_short_sha_and_is_cached(tmp_path) -> None:
|
||||
frontend, short_sha = _make_git_repo(tmp_path)
|
||||
|
||||
assert asset_version(str(frontend)) == short_sha
|
||||
# Second call: same value, served from the per-process cache.
|
||||
assert asset_version(str(frontend)) == short_sha
|
||||
assert asset_version.cache_info().hits >= 1
|
||||
|
||||
|
||||
def test_default_static_dir_comes_from_settings(tmp_path, monkeypatch) -> None:
|
||||
frontend, short_sha = _make_git_repo(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
caching, "get_settings", lambda: Settings(static_dir=str(frontend))
|
||||
)
|
||||
assert asset_version() == short_sha # no argument -> settings default
|
||||
|
||||
|
||||
def test_fallback_token_is_12_hex_and_stable_for_unchanged_tree(tmp_path) -> None:
|
||||
frontend = _make_plain_frontend(tmp_path)
|
||||
|
||||
token = asset_version(str(frontend))
|
||||
assert re.fullmatch(r"[0-9a-f]{12}", token)
|
||||
# Unchanged tree: same token even after the cache entry is dropped
|
||||
# (i.e. the hash itself is deterministic, not just the cache).
|
||||
asset_version.cache_clear()
|
||||
assert asset_version(str(frontend)) == token
|
||||
|
||||
|
||||
def test_fallback_token_changes_on_size_change(tmp_path) -> None:
|
||||
frontend = _make_plain_frontend(tmp_path)
|
||||
before = asset_version(str(frontend))
|
||||
|
||||
(frontend / "app.js").write_text("console.log('a longer payload')\n")
|
||||
asset_version.cache_clear()
|
||||
assert asset_version(str(frontend)) != before
|
||||
|
||||
|
||||
def test_fallback_token_changes_on_mtime_only(tmp_path) -> None:
|
||||
frontend = _make_plain_frontend(tmp_path)
|
||||
before = asset_version(str(frontend))
|
||||
|
||||
path = frontend / "app.js"
|
||||
now = os.stat(path).st_mtime_ns
|
||||
os.utime(path, ns=(now + 5_000, now + 5_000)) # mtime change, same size
|
||||
asset_version.cache_clear()
|
||||
assert asset_version(str(frontend)) != before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error",),
|
||||
[
|
||||
(FileNotFoundError("git not found"),),
|
||||
(subprocess.TimeoutExpired(cmd="git", timeout=5),),
|
||||
(subprocess.CalledProcessError(returncode=128, cmd="git"),),
|
||||
],
|
||||
ids=["git-missing", "git-timeout", "git-nonzero-exit"],
|
||||
)
|
||||
def test_git_failure_falls_back_to_content_hash(tmp_path, monkeypatch, error: Exception) -> None:
|
||||
# A ``.git`` exists, but git itself is broken -> content hash, no raise.
|
||||
repo = tmp_path / "proj"
|
||||
frontend = repo / "frontend"
|
||||
frontend.mkdir(parents=True)
|
||||
(repo / ".git").mkdir()
|
||||
(frontend / "styles.css").write_text("body { margin: 0 }\n")
|
||||
|
||||
def _boom(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(caching.subprocess, "run", _boom)
|
||||
token = asset_version(str(frontend))
|
||||
assert re.fullmatch(r"[0-9a-f]{12}", token)
|
||||
|
||||
|
||||
def test_git_success_with_empty_output_falls_back(tmp_path, monkeypatch) -> None:
|
||||
# git exits 0 but prints nothing (defensive guard) -> content hash.
|
||||
repo = tmp_path / "proj"
|
||||
frontend = repo / "frontend"
|
||||
frontend.mkdir(parents=True)
|
||||
(repo / ".git").mkdir()
|
||||
(frontend / "styles.css").write_text("body { margin: 0 }\n")
|
||||
|
||||
monkeypatch.setattr(
|
||||
caching.subprocess,
|
||||
"run",
|
||||
lambda *a, **k: subprocess.CompletedProcess(
|
||||
args=["git"], returncode=0, stdout=" \n", stderr=""
|
||||
),
|
||||
)
|
||||
token = asset_version(str(frontend))
|
||||
assert re.fullmatch(r"[0-9a-f]{12}", token)
|
||||
|
||||
|
||||
def test_missing_static_dir_is_dev(tmp_path) -> None:
|
||||
assert asset_version(str(tmp_path / "does-not-exist")) == "dev"
|
||||
|
||||
|
||||
def test_empty_static_dir_is_dev(tmp_path) -> None:
|
||||
empty = tmp_path / "frontend"
|
||||
empty.mkdir()
|
||||
assert asset_version(str(empty)) == "dev"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rewrite_asset_refs (task 02)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rewrite_versions_href_with_leading_slash() -> None:
|
||||
html = '<link rel="stylesheet" href="/assets/styles.css">'
|
||||
assert rewrite_asset_refs(html, TOKEN) == (
|
||||
'<link rel="stylesheet" href="/assets/styles.css?v=abc123">'
|
||||
)
|
||||
|
||||
|
||||
def test_rewrite_versions_src_without_leading_slash() -> None:
|
||||
html = '<script src="assets/markdown.js"></script>'
|
||||
assert rewrite_asset_refs(html, TOKEN) == (
|
||||
'<script src="assets/markdown.js?v=abc123"></script>'
|
||||
)
|
||||
|
||||
|
||||
def test_rewrite_versions_module_script_src() -> None:
|
||||
html = '<script type="module" src="/assets/app.js"></script>'
|
||||
assert rewrite_asset_refs(html, TOKEN) == (
|
||||
'<script type="module" src="/assets/app.js?v=abc123"></script>'
|
||||
)
|
||||
|
||||
|
||||
def test_rewrite_versions_every_ref_in_one_pass() -> None:
|
||||
html = (
|
||||
'<link rel="stylesheet" href="/assets/styles.css">'
|
||||
"<script src=\"assets/markdown.js\"></script>"
|
||||
'<script type="module" src="/assets/app.js"></script>'
|
||||
)
|
||||
assert rewrite_asset_refs(html, TOKEN) == (
|
||||
'<link rel="stylesheet" href="/assets/styles.css?v=abc123">'
|
||||
'<script src="assets/markdown.js?v=abc123"></script>'
|
||||
'<script type="module" src="/assets/app.js?v=abc123"></script>'
|
||||
)
|
||||
|
||||
|
||||
def test_rewrite_is_idempotent_for_already_versioned_refs() -> None:
|
||||
html = '<link rel="stylesheet" href="/assets/styles.css?v=abc123">'
|
||||
assert rewrite_asset_refs(html, "deadbeef") == html
|
||||
|
||||
|
||||
def test_rewrite_leaves_query_and_fragment_refs_alone() -> None:
|
||||
html = '<script src="/assets/app.js?x=1"></script><img src="/assets/logo.svg#frag">'
|
||||
assert rewrite_asset_refs(html, TOKEN) == html
|
||||
|
||||
|
||||
def test_rewrite_leaves_non_asset_refs_untouched() -> None:
|
||||
html = (
|
||||
'<a href="/sources.html">sources</a>'
|
||||
'<img src="data:image/png;base64,AAA">'
|
||||
'<a href="/login.html?next=/sources.html">login</a>'
|
||||
)
|
||||
assert rewrite_asset_refs(html, TOKEN) == html
|
||||
|
||||
|
||||
def test_rewrite_returns_html_unchanged_when_no_asset_refs() -> None:
|
||||
html = "<p>no assets here</p>"
|
||||
assert rewrite_asset_refs(html, TOKEN) == html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CachingMiddleware fallback branches (task 02)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _page_app() -> FastAPI:
|
||||
"""A bare app with a ``text/html`` route at ``/`` + the middleware."""
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
return '<html><head><link rel="stylesheet" href="/assets/styles.css"></head></html>'
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
caching.configure_caching(app)
|
||||
return app
|
||||
|
||||
|
||||
def test_middleware_success_path_rewrites_page_and_sets_no_cache() -> None:
|
||||
client = TestClient(_page_app())
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
token = caching.asset_version()
|
||||
assert f'href="/assets/styles.css?v={token}"' in r.text
|
||||
assert 'href="/assets/styles.css">' not in r.text
|
||||
|
||||
|
||||
def test_middleware_buffer_failure_keeps_body_and_sets_no_cache(monkeypatch) -> None:
|
||||
"""If buffering/token resolution fails, the ORIGINAL streaming body is
|
||||
served unmodified — with ``no-cache`` — never an empty page."""
|
||||
|
||||
def _boom() -> str:
|
||||
raise RuntimeError("token blew up")
|
||||
|
||||
monkeypatch.setattr(caching, "asset_version", _boom)
|
||||
client = TestClient(_page_app())
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert r.text == (
|
||||
"<html><head><link rel=\"stylesheet\" href=\"/assets/styles.css\"></head></html>"
|
||||
)
|
||||
|
||||
|
||||
def test_middleware_non_html_body_on_page_path_gets_no_cache_only() -> None:
|
||||
"""A page path whose response is not ``text/html`` (e.g. the 404 JSON
|
||||
when the static dir is missing) is revalidated but never rewritten."""
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/")
|
||||
def not_found() -> JSONResponse:
|
||||
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
||||
|
||||
caching.configure_caching(app)
|
||||
client = TestClient(app)
|
||||
r = client.get("/")
|
||||
assert r.status_code == 404
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert r.json() == {"detail": "Not Found"} # body untouched
|
||||
|
||||
|
||||
def test_middleware_rewrite_failure_reserves_original_bytes(monkeypatch) -> None:
|
||||
"""If the body buffered fine but the rewrite itself fails, the ORIGINAL
|
||||
bytes are re-served (never an empty page) with ``no-cache``."""
|
||||
|
||||
def _boom(html: str, token: str) -> str:
|
||||
raise ValueError("rewrite blew up")
|
||||
|
||||
monkeypatch.setattr(caching, "rewrite_asset_refs", _boom)
|
||||
client = TestClient(_page_app())
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert r.text == (
|
||||
"<html><head><link rel=\"stylesheet\" href=\"/assets/styles.css\"></head></html>"
|
||||
)
|
||||
|
||||
|
||||
def test_middleware_leaves_api_responses_byte_identical() -> None:
|
||||
"""``/api/*`` gets no injected headers at all (no cache-control)."""
|
||||
client = TestClient(_page_app())
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert "cache-control" not in r.headers
|
||||
assert r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_read_body_buffers_plain_response_bytes() -> None:
|
||||
from starlette.responses import Response
|
||||
|
||||
resp = Response(content=b"<html>hi</html>", media_type="text/html")
|
||||
assert asyncio.run(caching._read_body(resp)) == b"<html>hi</html>"
|
||||
|
||||
|
||||
def test_read_body_copies_plain_response_memoryview() -> None:
|
||||
from starlette.responses import Response
|
||||
|
||||
resp = Response(content=memoryview(b"<html>hi</html>"), media_type="text/html")
|
||||
assert asyncio.run(caching._read_body(resp)) == b"<html>hi</html>"
|
||||
|
||||
|
||||
def test_read_body_drains_streaming_response() -> None:
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
yield b"<a>"
|
||||
yield b"</a>"
|
||||
|
||||
resp = StreamingResponse(content=gen(), media_type="text/html")
|
||||
assert asyncio.run(caching._read_body(resp)) == b"<a></a>"
|
||||
@@ -19,3 +19,14 @@ def test_create_app_warns_and_serves_api_only_without_static_dir(
|
||||
assert client.get("/api/health").status_code == 200
|
||||
# …but the static mount is absent (no index page).
|
||||
assert client.get("/").status_code == 404
|
||||
|
||||
|
||||
def test_create_app_wires_caching_middleware() -> None:
|
||||
"""Phase 33: the app factory always attaches the cache-busting
|
||||
middleware (by name) — even when the static dir is missing, so the
|
||||
/api/* no-touch guarantee holds in every environment."""
|
||||
app2 = main_mod.create_app()
|
||||
# ``mw.cls`` is starlette's opaque ``_MiddlewareFactory`` protocol —
|
||||
# reach for ``__name__`` the same way starlette's own __repr__ does.
|
||||
middleware_names = [getattr(mw.cls, "__name__", "") for mw in app2.user_middleware]
|
||||
assert "CachingMiddleware" in middleware_names
|
||||
|
||||
Reference in New Issue
Block a user