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.
5.2 KiB
5.2 KiB
Story: Cache Busting (Un-stick the Pages)
Phase: 33_cache_busting/ · E2E: tests/e2e/test_cache_busting.py
Narrative
As anyone using Brain of Reese, I want a new deploy to be visible without a hard refresh. Today the five HTML pages reference their CSS/JS with no version at all, so the browser keeps serving stale assets long after the app has moved on — the "the pages are too sticky" report. I want a deploy (a commit) to change what the browser fetches, automatically, with no new services and no CDN.
- Given the app has been redeployed (a new commit)
- When I open — or revisit — any page
- Then the page itself is always revalidated (never served from cache
unchecked), its assets are fetched from versioned URLs
(
?v=<token>— the git short SHA of the deploy, so each commit changes them), and the API — the SSE chat stream in particular — is left byte-for-byte alone.
Acceptance criteria
- HTML always revalidates. All five pages (
/,/index.html,/sources.html,/document.html,/login.html,/tuning.html) shipCache-Control: no-cache(the page is the cheap redirector to the long-cached assets). - Assets are versioned. Every local
href/srcasset reference on those pages carries?v=<token>— rewritten in flight by one middleware (a small regex overhref/srcassets/…refs, leading slash optional, idempotent: an existing query string or fragment is never double-tagged, and non-asset references pass through). The token is non-empty and stable across requests within a process (one token per process, computed once). - Immutable assets.
/assets/*responses shipCache-Control: public, max-age=31536000, immutable— safe precisely because the URL carries the token (a deploy changes the token, hence the URL). The unversioned path keeps resolving (the static mount ignores the query string), so old tabs and direct links still work. - The token is the deploy.
app/core/caching.py::asset_version(): a git checkout (the project root next tofrontend/has a.git— the homelab reality) →git rev-parse --short HEADwith a 5 s timeout (a deploy must not hang a boot); no.git, git missing, non-zero exit, timeout, or empty output → the first 12 hex chars of SHA-256 over the sortedrelpath:mtime_ns:sizeof every regular file underfrontend/; a missing/empty static dir →"dev". Cached withfunctools.cache(==lru_cache(maxsize=None)) — zero per-request git/file cost. A new commit or a frontend content change flips the token on the next process start. - The API is untouched. The middleware touches exactly two response
shapes (the page paths: body rewrite +
no-cache;/assets/*: header only). Everything else — all of/api/*, including the SSE chat stream — passes through with no header changes and no body read (SSE keeps theno-cacheits endpoint sets itself). The no-CDN integration test stays green (the rewritten references are same-origin). - Quality gates. Unit (
tests/unit/test_caching.py): the token's git path, fallback path (stable across an unchanged tree, flips on a touch/modify +cache_clear()), failure path (git raising → content hash, no exception), empty-dir"dev"; the purerewrite_asset_refs(versionedhref/src, no-leading-slash refs, module scripts, idempotency,#fragment/existing-query left alone, non-asset refs untouched). Integration (tests/integration/test_api.py): every pageno-cache+ versioned references, asset headers, nocache-controlinjected on/api/health, SSE chat tests unchanged and green. Coverage:app/core/caching.py100 %,app/> 90 % (TOTAL ≥ pre-change). - E2E (this story's gate).
tests/e2e/test_cache_busting.py, run in isolation: a real (fresh-profile) Chromium asserts the wire truth — the document responses areno-cache, the CSS/JS request URLs the browser actually makes carry one shared token (matching this checkout'sasset_version()), the asset responses are immutable for a year, and/api/*— including a live SSE chat turn that streams deltas and completes withdone— is unaffected (mock LLM, no live aipi;podman compose up -d dbfor the chat check).
Playwright Mapping Rule
tests/e2e/test_cache_busting.py — run in isolation (Chromium +
podman compose up -d db; mock LLM via the shared conftest.py session
app; no per-module overrides needed):
test_html_pages_are_no_cache_and_versioned→ AC 1 + 2 + 3 (the/document response isno-cache; thestyles.cssrequest URL carries?v=<token>and its response isimmutable+max-age=31536000; theapp.jsrequest URL carries the same token; the served HTML carries no unversioned"/assets/styles.css"reference).test_other_pages_share_the_token→ AC 1 + 2 (the/sources.htmland/login.htmldocument responses areno-cache; both pages' stylesheet requests carry the same token).test_api_responses_unaffected→ AC 5 (/api/healthhas nocache-controlinjected — the endpoint's baseline headers only; the SSE chat POST still streams deltas and completes withdone).