Files
brain-of-reese/tests/integration/test_caching_revalidation.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

224 lines
9.3 KiB
Python

"""Integration: the phase-54 revalidation contract against the REAL app.
Phase 33's two cache-busting layers (``asset_version()`` +
``CachingMiddleware``) rewrite the known HTML pages to carry
``?v=<token>`` asset references — but the ``etag`` / ``last-modified``
validators Starlette publishes for a page describe the STATIC FILE, not
the rewritten body this process built from its own token. A conditional
GET that matched those validators used to 304 out of the rewrite: the
browser kept the HTML it already had, whose ``?v=`` pinned the PREVIOUS
commit's CSS/JS — cached ``immutable`` for a year. This suite pins the
fix end-to-end (real ``app.main:app``, real ``StaticFiles`` mount on the
real ``frontend/`` tree, real ``asset_version()`` token — no mocks):
* every known page (``HTML_PAGES``) AND the dynamic ``/shared/<token>``
page 200s on a conditional GET, always with the current
``?v=<token>`` body and no validators;
* ``/assets/*`` is untouched: ``immutable`` for a year, validators
intact, a conditional GET on the versioned URL still 304s (that 304
is safe — the URL itself carries the version);
* ``/api/*`` stays byte-identical: no ``cache-control`` injected, no
validators, conditional headers pass through (the SSE chat stream's
pass-through is pinned by ``test_chat_api.py`` — the regression run
below).
Requires: podman compose up -d db
"""
from __future__ import annotations
import os
import re
from collections.abc import Iterator
from pathlib import Path
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.caching import (
ASSET_CACHE_CONTROL,
HTML_CACHE_CONTROL,
HTML_PAGES,
asset_version,
)
REPO = Path(__file__).resolve().parents[2]
FRONTEND = REPO / "frontend"
def _token_ref_re(token: str) -> re.Pattern[str]:
"""A local ``assets/…`` href/src reference that already carries
``?v=<token>`` (the middleware's rewrite output)."""
return re.compile(r'(?:src|href)="(?:/)?assets/[^"?#]*\?v=' + re.escape(token) + r'"')
def file_validators(page_file: Path) -> tuple[str, str]:
"""The etag / last-modified Starlette would stamp on the underlying
static file — exactly what a browser revalidates against.
``stat_result`` is passed up front: starlette 1.x's ``FileResponse``
defers the stat to ``__call__``, so without it the headers carry no
validators yet (same pattern as the unit suite's ``_file_validators``).
"""
from starlette.responses import FileResponse
headers = FileResponse(page_file, stat_result=os.stat(page_file)).headers
return headers["etag"], headers["last-modified"]
#: Phase 76 (task 01): the shell-served view paths — the URL is a
#: navbar view, the file on disk is the SHELL (the shell route in
#: app/main.py serves frontend/index.html for it). The etag
#: computation below must use the file that actually backs the
#: response, or the conditional-GET probe would carry a validator no
#: browser ever saw. Tasks 02/03 extended this as the remaining views
#: folded in (task 03 — History — completed the phase-76 set: all
#: four non-chat navbar views; phase 79 task 06 adds the sixth —
#: Tokens). The page CONTRACT itself is unchanged: the
#: phase-33 middleware wraps the whole app and lists the path in
#: HTML_PAGES, so the shell-route response is normalized exactly like
#: a static page (200, no-cache, ?v=, no validators — asserted by
#: _assert_page_contract below).
SHELL_BACKED_PAGES = {
"/tuning.html": "index.html", # phase 76 task 01
"/sources.html": "index.html", # phase 76 task 02
"/git-sources.html": "index.html", # phase 76 task 02
"/history.html": "index.html", # phase 76 task 03
"/tokens.html": "index.html", # phase 79 task 06
}
def _page_file(path: str) -> Path:
"""The static file backing a page path (``/`` → ``index.html``;
the shell-served view paths → the shell, ``SHELL_BACKED_PAGES``)."""
name = SHELL_BACKED_PAGES.get(path, path.lstrip("/") or "index.html")
file = FRONTEND / name
assert file.is_file(), f"missing page file for {path}: {file}"
return file
def _assert_page_contract(response: httpx.Response, token: str) -> None:
"""The phase-54 page contract on any known page: a full 200 with the
CURRENT process token on the asset refs, ``Cache-Control: no-cache``,
and NO validators (a page must never be revalidated against a
validator this process published)."""
assert response.status_code == 200, (
f"a page path must never 304 (got {response.status_code})"
)
assert response.headers["cache-control"] == HTML_CACHE_CONTROL
assert "etag" not in response.headers
assert "last-modified" not in response.headers
assert f"?v={token}" in response.text
assert _token_ref_re(token).search(response.text), (
"no local assets/ ref carries ?v=<current token>"
)
@pytest.fixture(autouse=True)
def clean_chats(db: Session) -> Iterator[None]:
"""``saved_chats`` is global state — the share test writes one row
(house pattern from ``test_chats_api.py``)."""
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
db.execute(text("TRUNCATE saved_chats"))
db.commit()
def test_every_known_page_200s_on_conditional_get(client: TestClient) -> None:
"""THE phase-54 regression, real app: for EVERY known page, a plain
GET sets the contract, then a conditional GET carrying the static
FILE's etag (what a browser captured pre-fix) must still 200 with
the SAME rewritten body — pre-fix the loop failed on the very first
304, pinning the browser on the previous commit's immutable assets."""
token = asset_version()
for path in HTML_PAGES:
plain = client.get(path)
_assert_page_contract(plain, token)
etag, _ = file_validators(_page_file(path))
conditional = client.get(path, headers={"if-none-match": etag})
_assert_page_contract(conditional, token)
assert conditional.content == plain.content, (
f"{path}: the conditional 200 must serve the same rewritten body"
)
def test_dynamic_shared_page_200s_on_conditional_get(admin_client: TestClient) -> None:
"""The dynamic /shared/<token> page (phase 51) gets the same contract
via the real save+share flow: a conditional GET carrying the
``shared.html`` file's etag must still 200 with the rewritten body —
the route's ``FileResponse`` honours conditional headers, so without
the inbound strip this page 304'd too."""
token = asset_version()
created = admin_client.post(
"/api/chats",
json={
"messages": [
{"who": "user", "text": "How did I install gitlab?"},
{"who": "brain", "text": "You've got this!"},
],
"share": True,
},
)
assert created.status_code == 201
share_url = created.json()["share_url"]
plain = admin_client.get(share_url)
_assert_page_contract(plain, token)
etag, _ = file_validators(FRONTEND / "shared.html")
conditional = admin_client.get(share_url, headers={"if-none-match": etag})
_assert_page_contract(conditional, token)
assert conditional.content == plain.content
def test_assets_keep_immutable_validators_and_304(client: TestClient) -> None:
"""The inbound strip must NOT have widened to /assets/*: the versioned
asset URL keeps its validators and still 304s on a conditional GET —
that 304 is safe because the URL itself carries ?v=<token>."""
token = asset_version()
url = f"/assets/styles.css?v={token}"
plain = client.get(url)
assert plain.status_code == 200
assert plain.headers["cache-control"] == ASSET_CACHE_CONTROL
assert "etag" in plain.headers
assert "last-modified" in plain.headers
conditional = client.get(url, headers={"if-none-match": plain.headers["etag"]})
assert conditional.status_code == 304 # versioned-URL 304s stay safe
assert conditional.content == b""
assert conditional.headers["cache-control"] == ASSET_CACHE_CONTROL
def test_api_paths_get_no_cache_headers_and_untouched_stream(client: TestClient) -> None:
"""/api/* stays byte-identical: no cache-control injected, no
validators published, and conditional headers pass through to the
route untouched (the SSE chat stream's pass-through is pinned by
``tests/integration/test_chat_api.py`` — the regression run below)."""
plain = client.get("/api/health")
assert plain.status_code == 200
assert "cache-control" not in plain.headers
assert "etag" not in plain.headers
assert "last-modified" not in plain.headers
conditional = client.get("/api/health", headers={"if-none-match": "x"})
assert conditional.status_code == 200 # pass-through — the strip is page-scoped
assert conditional.json() == plain.json()
assert "cache-control" not in conditional.headers
def test_page_token_matches_process_token(client: TestClient) -> None:
"""The token embedded in the served page equals ``asset_version()`` —
the per-process ``functools.cache`` contract: one page load can never
mix two versions (phase 54, assumption 5)."""
token = asset_version()
response = client.get("/")
assert response.status_code == 200
match = re.search(r'styles\.css\?v=([^"]+)"', response.text)
assert match is not None, "styles.css ref not found in the served page"
assert match.group(1) == token