4.3 KiB
4.3 KiB
Task 02 — Caching middleware + wiring + integration tests
Phase: 33_cache_busting · Source: TODO.md:6 — "the pages are too sticky" (HTML revalidation + versioned asset URLs + immutable assets)
Story: .agent/user_stories/cache-busting.md
Objective
Apply the caching behavior at the transport layer: HTML pages no-cache with ?v=<token> on every local asset reference; /assets/* immutable 1-year; everything else (all /api/*, including SSE) untouched.
Work
app/core/caching.py(extend task 01's module):HTML_PAGES: tuple[str, ...] = ("/", "/index.html", "/sources.html", "/document.html", "/login.html", "/tuning.html")._ASSET_REF_RE = re.compile(r'((?:href|src)="(?:/)?assets/[^"?#]+)(")')— matches<link rel="stylesheet" href="/assets/styles.css">,<script src="assets/markdown.js"></script>(no leading slash!), and<script type="module" src="/assets/app.js">; the rewrite appends?v=<token>before the closing quote, only when the reference has no query/hash yet (idempotent — never a double?v=).def rewrite_asset_refs(html: str, token: str) -> str— the pure, unit-testable rewrite.def configure_caching(app: FastAPI) -> None— one@app.middleware("http")(or equivalent Starlette middleware) that, after the response is produced:path.startswith("/assets/")→response.headers["Cache-Control"] = "public, max-age=31536000, immutable"(header only — never touch the body).request.url.pathinHTML_PAGESand response content-type istext/html→Cache-Control: no-cache+response.body = rewrite_asset_refs(body, asset_version())(body viaawait response.body()— works for the buffered StaticFiles/FileResponse HTML responses; on any error or non-text/html, fall through to the unmodified response with onlyno-cache).- everything else → completely untouched (no header, no body work). The
/api/*SSE stream in particular must not be read or rewritten.
app/main.py—from app.core.caching import configure_caching; callconfigure_caching(app)insidecreate_app()after the routers (order: middleware wraps the whole app — call it beforereturn app).tests/unit/test_caching.py(extend) —rewrite_asset_refs:- versioned:
href="/assets/styles.css"→href="/assets/styles.css?v=abc123";src="assets/markdown.js"(no slash) → versioned;src="/assets/app.js"(module script) → versioned. - idempotent: an already-
?v=-tagged reference is not double-tagged; a#fragmentor existing?queryreference is left alone. - non-asset references untouched (
href="/sources.html",href="data:…",href="/login.html?next=…").
- versioned:
tests/integration/test_api.py(extend):GET /→ 200,cache-control: no-cache; body containshref="/assets/styles.css?v=<token>"with a non-empty token matchingasset_version(); the unversioned stringhref="/assets/styles.css">is gone from the body.- each of the other four pages (
/sources.html,/document.html,/login.html,/tuning.html) →no-cache+ at least one versioned asset reference. GET /assets/styles.css→ 200,cache-controlcontainsimmutableandmax-age=31536000.GET /api/health→ response has nocache-controlinjected (baseline: FastAPI's default) — assert equality with the pre-middleware behavior; the SSE chat endpoint (tests/integration/test_chat_api.py) stays green unmodified.- the existing no-CDN test (
test_html_pages_served_locally_no_cdn) stays green — the rewritten references are still same-origin.
tests/unit/test_main.py— app creation still succeeds with the middleware wired (existing creation tests stay green; add an assertion thatcreate_app()'s middleware stack includes the caching middleware by name).
Testing & Quality
- Unit: Work steps 3 + 5; Integration: Work step 4.
- Coverage: >90% on
app/core/caching.py;app/TOTAL ≥ pre-change.
Completion Criteria
- All new unit + integration tests green; the full suite green (especially
test_chat_api.py— SSE unaffected). - Manual check:
curl -si localhost:8000/ | grep -i cache-control→no-cache; the HTML body shows?v=…asset refs;curl -si localhost:8000/assets/styles.css | grep -i cache-control→ immutable. uv run ruff check . && uv run pyrightclean.