"""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 from pathlib import Path from typing import Any import httpx from playwright.sync_api import Page REPO = Path(__file__).resolve().parents[2] CHAT_QUESTION = "How is my Kubernetes cluster set up?" 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 _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 and /history.html (phase 50): each document revalidates, and all three 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 assert sources_token == login_token == history_token == token 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"