# 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=` — 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 1. **HTML always revalidates.** All five pages (`/`, `/index.html`, `/sources.html`, `/document.html`, `/login.html`, `/tuning.html`) ship `Cache-Control: no-cache` (the page is the cheap redirector to the long-cached assets). 2. **Assets are versioned.** Every local `href`/`src` asset reference on those pages carries `?v=` — rewritten in flight by one middleware (a small regex over `href`/`src` `assets/…` 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). 3. **Immutable assets.** `/assets/*` responses ship `Cache-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. 4. **The token is the deploy.** `app/core/caching.py::asset_version()`: a git checkout (the project root next to `frontend/` has a `.git` — the homelab reality) → `git rev-parse --short HEAD` with 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 sorted `relpath:mtime_ns:size` of every regular file under `frontend/`; a missing/empty static dir → `"dev"`. Cached with `functools.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. 5. **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 the `no-cache` its endpoint sets itself). The no-CDN integration test stays green (the rewritten references are same-origin). 6. **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 pure `rewrite_asset_refs` (versioned `href`/`src`, no-leading-slash refs, module scripts, idempotency, `#fragment`/existing-query left alone, non-asset refs untouched). Integration (`tests/integration/test_api.py`): every page `no-cache` + versioned references, asset headers, no `cache-control` injected on `/api/health`, SSE chat tests unchanged and green. Coverage: `app/core/caching.py` 100 %, `app/` > 90 % (TOTAL ≥ pre-change). 7. **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 are `no-cache`, the CSS/JS request URLs the browser actually makes carry one shared token (matching this checkout's `asset_version()`), the asset responses are immutable for a year, and `/api/*` — including a live SSE chat turn that streams deltas and completes with `done` — is unaffected (mock LLM, no live aipi; `podman compose up -d db` for 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): 1. `test_html_pages_are_no_cache_and_versioned` → AC 1 + 2 + 3 (the `/` document response is `no-cache`; the `styles.css` request URL carries `?v=` and its response is `immutable` + `max-age=31536000`; the `app.js` request URL carries the **same** token; the served HTML carries no unversioned `"/assets/styles.css"` reference). 2. `test_other_pages_share_the_token` → AC 1 + 2 (the `/sources.html` and `/login.html` document responses are `no-cache`; both pages' stylesheet requests carry the same token). 3. `test_api_responses_unaffected` → AC 5 (`/api/health` has no `cache-control` injected — the endpoint's baseline headers only; the SSE chat POST still streams deltas and completes with `done`).