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.
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.