"""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=`` (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 import re from pathlib import Path from typing import Any import httpx from playwright.sync_api import Page from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] CHAT_QUESTION = "How is my Kubernetes cluster set up?" SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$") 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 _admin_cookies(page: Page) -> dict[str, str]: """The signed session cookies the browser holds after a form login — used to call the admin API with plain httpx.""" return { c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c } 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, /login.html, /history.html (phase 50) and /doc-edit.html (phase 59): each document revalidates, and all four 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") history_token = navigate("/history.html") # phase 50: the new page docedit_token = navigate("/doc-edit.html") # phase 59: the doc edit screen assert sources_token == login_token == history_token == docedit_token == token def test_shared_page_is_no_cache_and_versioned( page: Page, app_url: str, db_ready: None ) -> None: """`/shared/` (phase 51, the dynamic share page): the same contract as the static HTML pages — the document revalidates (no-cache) and the served HTML's asset refs are `?v=`-tagged (the middleware's prefix extension, task 01). The chat is created + shared via the admin API for the test.""" token = _expected_token() assert token, "the version token must be non-empty" login(page, app_url, next="/") cookies = _admin_cookies(page) r = httpx.post( f"{app_url}/api/chats", json={ "messages": [ {"who": "user", "text": "cache-busting shared-page probe"}, {"who": "brain", "text": "Shared for the cache contract."}, ], "share": True, }, timeout=10, cookies=cookies, ) assert r.status_code == 201 body = r.json() assert SHARE_URL_RE.fullmatch(body["share_url"]), body["share_url"] try: with page.expect_response(lambda r: "/assets/styles.css" in r.url) as css_info: doc = page.goto(app_url + body["share_url"]) # The document: always revalidated, like every HTML page. assert doc is not None assert doc.headers["cache-control"] == "no-cache" # The CSS request the browser actually makes carries the # process token… assert _version_token(css_info.value.url) == token # …and the served HTML references its assets versioned (the # page uses ABSOLUTE /assets refs — required for the nested # /shared/ path). html = page.content() assert f'href="/assets/styles.css?v={token}"' in html assert f'src="/assets/brand.js?v={token}"' in html assert f'src="/assets/markdown.js?v={token}"' in html assert f'src="/assets/shared.js?v={token}"' in html # No unversioned reference survives the rewrite. assert 'href="/assets/styles.css"' not in html assert 'src="/assets/shared.js"' not in html finally: httpx.delete(f"{app_url}/api/chats/{body['id']}", timeout=10, cookies=cookies) 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"