212 lines
8.3 KiB
Python
212 lines
8.3 KiB
Python
"""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=<token>`` (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/<token>` (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/<token> 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"
|