455 lines
16 KiB
Python
455 lines
16 KiB
Python
"""Unit tests: the frontend asset version token (phase 33, app/core/caching.py).
|
|
|
|
Covers the four token paths:
|
|
* git repo -> ``git rev-parse --short HEAD`` (stable, cached).
|
|
* fallback -> 12-hex content hash; stable for an unchanged tree, flips on
|
|
a size or mtime change once the per-process cache is cleared.
|
|
* failure -> a ``.git`` present but git broken (missing / timeout /
|
|
non-zero exit) falls back to the content hash without raising.
|
|
* empty -> a missing or empty static dir yields ``"dev"``.
|
|
|
|
Plus the asset-reference rewrite (``rewrite_asset_refs``) and the
|
|
CachingMiddleware fallback branches (task 02).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import uuid
|
|
from collections.abc import AsyncIterator, Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app.core.caching as caching
|
|
from app.config import Settings
|
|
from app.core.caching import asset_version, rewrite_asset_refs
|
|
|
|
TOKEN = "abc123"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_token_cache() -> Iterator[None]:
|
|
# The token is lru_cached per process — every case starts from a clean
|
|
# slate and never leaks its entry into the next case.
|
|
asset_version.cache_clear()
|
|
yield
|
|
asset_version.cache_clear()
|
|
|
|
|
|
def _git(repo: Path, *args: str) -> None:
|
|
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
|
|
|
|
|
|
def _make_git_repo(base: Path) -> tuple[Path, str]:
|
|
"""A repo with a committed ``frontend/`` file; returns (frontend, short SHA)."""
|
|
repo = base / "proj"
|
|
frontend = repo / "frontend"
|
|
frontend.mkdir(parents=True)
|
|
(frontend / "styles.css").write_text("body { margin: 0 }\n")
|
|
_git(repo, "init", "-q")
|
|
_git(repo, "add", "frontend")
|
|
_git(
|
|
repo,
|
|
"-c",
|
|
"user.name=test",
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"commit.gpgsign=false", # the fixture commit never signs (env gpg)
|
|
"commit",
|
|
"-q",
|
|
"-m",
|
|
"init",
|
|
)
|
|
proc = subprocess.run(
|
|
["git", "-C", str(repo), "rev-parse", "--short", "HEAD"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return frontend, proc.stdout.strip()
|
|
|
|
|
|
def _make_plain_frontend(tmp_path: Path) -> Path:
|
|
frontend = tmp_path / "frontend"
|
|
frontend.mkdir()
|
|
(frontend / "app.js").write_text("console.log('hi')\n")
|
|
(frontend / "styles.css").write_text("body { margin: 0 }\n")
|
|
return frontend
|
|
|
|
|
|
def test_git_repo_token_matches_short_sha_and_is_cached(tmp_path) -> None:
|
|
frontend, short_sha = _make_git_repo(tmp_path)
|
|
|
|
assert asset_version(str(frontend)) == short_sha
|
|
# Second call: same value, served from the per-process cache.
|
|
assert asset_version(str(frontend)) == short_sha
|
|
assert asset_version.cache_info().hits >= 1
|
|
|
|
|
|
def test_default_static_dir_comes_from_settings(tmp_path, monkeypatch) -> None:
|
|
frontend, short_sha = _make_git_repo(tmp_path)
|
|
monkeypatch.setattr(
|
|
caching, "get_settings", lambda: Settings(static_dir=str(frontend))
|
|
)
|
|
assert asset_version() == short_sha # no argument -> settings default
|
|
|
|
|
|
def test_fallback_token_is_12_hex_and_stable_for_unchanged_tree(tmp_path) -> None:
|
|
frontend = _make_plain_frontend(tmp_path)
|
|
|
|
token = asset_version(str(frontend))
|
|
assert re.fullmatch(r"[0-9a-f]{12}", token)
|
|
# Unchanged tree: same token even after the cache entry is dropped
|
|
# (i.e. the hash itself is deterministic, not just the cache).
|
|
asset_version.cache_clear()
|
|
assert asset_version(str(frontend)) == token
|
|
|
|
|
|
def test_fallback_token_changes_on_size_change(tmp_path) -> None:
|
|
frontend = _make_plain_frontend(tmp_path)
|
|
before = asset_version(str(frontend))
|
|
|
|
(frontend / "app.js").write_text("console.log('a longer payload')\n")
|
|
asset_version.cache_clear()
|
|
assert asset_version(str(frontend)) != before
|
|
|
|
|
|
def test_fallback_token_changes_on_mtime_only(tmp_path) -> None:
|
|
frontend = _make_plain_frontend(tmp_path)
|
|
before = asset_version(str(frontend))
|
|
|
|
path = frontend / "app.js"
|
|
now = os.stat(path).st_mtime_ns
|
|
os.utime(path, ns=(now + 5_000, now + 5_000)) # mtime change, same size
|
|
asset_version.cache_clear()
|
|
assert asset_version(str(frontend)) != before
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error",),
|
|
[
|
|
(FileNotFoundError("git not found"),),
|
|
(subprocess.TimeoutExpired(cmd="git", timeout=5),),
|
|
(subprocess.CalledProcessError(returncode=128, cmd="git"),),
|
|
],
|
|
ids=["git-missing", "git-timeout", "git-nonzero-exit"],
|
|
)
|
|
def test_git_failure_falls_back_to_content_hash(tmp_path, monkeypatch, error: Exception) -> None:
|
|
# A ``.git`` exists, but git itself is broken -> content hash, no raise.
|
|
repo = tmp_path / "proj"
|
|
frontend = repo / "frontend"
|
|
frontend.mkdir(parents=True)
|
|
(repo / ".git").mkdir()
|
|
(frontend / "styles.css").write_text("body { margin: 0 }\n")
|
|
|
|
def _boom(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
raise error
|
|
|
|
monkeypatch.setattr(caching.subprocess, "run", _boom)
|
|
token = asset_version(str(frontend))
|
|
assert re.fullmatch(r"[0-9a-f]{12}", token)
|
|
|
|
|
|
def test_git_success_with_empty_output_falls_back(tmp_path, monkeypatch) -> None:
|
|
# git exits 0 but prints nothing (defensive guard) -> content hash.
|
|
repo = tmp_path / "proj"
|
|
frontend = repo / "frontend"
|
|
frontend.mkdir(parents=True)
|
|
(repo / ".git").mkdir()
|
|
(frontend / "styles.css").write_text("body { margin: 0 }\n")
|
|
|
|
monkeypatch.setattr(
|
|
caching.subprocess,
|
|
"run",
|
|
lambda *a, **k: subprocess.CompletedProcess(
|
|
args=["git"], returncode=0, stdout=" \n", stderr=""
|
|
),
|
|
)
|
|
token = asset_version(str(frontend))
|
|
assert re.fullmatch(r"[0-9a-f]{12}", token)
|
|
|
|
|
|
def test_missing_static_dir_is_dev(tmp_path) -> None:
|
|
assert asset_version(str(tmp_path / "does-not-exist")) == "dev"
|
|
|
|
|
|
def test_empty_static_dir_is_dev(tmp_path) -> None:
|
|
empty = tmp_path / "frontend"
|
|
empty.mkdir()
|
|
assert asset_version(str(empty)) == "dev"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTML_PAGES registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_html_pages_include_history() -> None:
|
|
"""Phase 50: the History page is registered in HTML_PAGES — without
|
|
this entry it would serve unversioned asset refs, which the
|
|
immutable-for-a-year asset caching would pin to stale CSS after a
|
|
deploy (the phase-35 git-sources lesson). The entry is ADDED —
|
|
every pre-phase-50 page stays registered."""
|
|
for path in (
|
|
"/",
|
|
"/index.html",
|
|
"/sources.html",
|
|
"/document.html",
|
|
"/login.html",
|
|
"/tuning.html",
|
|
"/git-sources.html",
|
|
"/history.html",
|
|
"/shared.html", # phase 51: the shared page's static path
|
|
):
|
|
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# rewrite_asset_refs (task 02)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_rewrite_versions_href_with_leading_slash() -> None:
|
|
html = '<link rel="stylesheet" href="/assets/styles.css">'
|
|
assert rewrite_asset_refs(html, TOKEN) == (
|
|
'<link rel="stylesheet" href="/assets/styles.css?v=abc123">'
|
|
)
|
|
|
|
|
|
def test_rewrite_versions_src_without_leading_slash() -> None:
|
|
html = '<script src="assets/markdown.js"></script>'
|
|
assert rewrite_asset_refs(html, TOKEN) == (
|
|
'<script src="assets/markdown.js?v=abc123"></script>'
|
|
)
|
|
|
|
|
|
def test_rewrite_versions_module_script_src() -> None:
|
|
html = '<script type="module" src="/assets/app.js"></script>'
|
|
assert rewrite_asset_refs(html, TOKEN) == (
|
|
'<script type="module" src="/assets/app.js?v=abc123"></script>'
|
|
)
|
|
|
|
|
|
def test_rewrite_versions_every_ref_in_one_pass() -> None:
|
|
html = (
|
|
'<link rel="stylesheet" href="/assets/styles.css">'
|
|
"<script src=\"assets/markdown.js\"></script>"
|
|
'<script type="module" src="/assets/app.js"></script>'
|
|
)
|
|
assert rewrite_asset_refs(html, TOKEN) == (
|
|
'<link rel="stylesheet" href="/assets/styles.css?v=abc123">'
|
|
'<script src="assets/markdown.js?v=abc123"></script>'
|
|
'<script type="module" src="/assets/app.js?v=abc123"></script>'
|
|
)
|
|
|
|
|
|
def test_rewrite_is_idempotent_for_already_versioned_refs() -> None:
|
|
html = '<link rel="stylesheet" href="/assets/styles.css?v=abc123">'
|
|
assert rewrite_asset_refs(html, "deadbeef") == html
|
|
|
|
|
|
def test_rewrite_leaves_query_and_fragment_refs_alone() -> None:
|
|
html = '<script src="/assets/app.js?x=1"></script><img src="/assets/logo.svg#frag">'
|
|
assert rewrite_asset_refs(html, TOKEN) == html
|
|
|
|
|
|
def test_rewrite_leaves_non_asset_refs_untouched() -> None:
|
|
html = (
|
|
'<a href="/sources.html">sources</a>'
|
|
'<img src="data:image/png;base64,AAA">'
|
|
'<a href="/login.html?next=/sources.html">login</a>'
|
|
)
|
|
assert rewrite_asset_refs(html, TOKEN) == html
|
|
|
|
|
|
def test_rewrite_returns_html_unchanged_when_no_asset_refs() -> None:
|
|
html = "<p>no assets here</p>"
|
|
assert rewrite_asset_refs(html, TOKEN) == html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CachingMiddleware fallback branches (task 02)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _page_app() -> FastAPI:
|
|
"""A bare app with a ``text/html`` route at ``/`` + the middleware."""
|
|
app = FastAPI()
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index() -> str:
|
|
return '<html><head><link rel="stylesheet" href="/assets/styles.css"></head></html>'
|
|
|
|
@app.get("/api/health")
|
|
def health() -> JSONResponse:
|
|
return JSONResponse({"status": "ok"})
|
|
|
|
caching.configure_caching(app)
|
|
return app
|
|
|
|
|
|
def test_middleware_success_path_rewrites_page_and_sets_no_cache() -> None:
|
|
client = TestClient(_page_app())
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
assert r.headers["cache-control"] == "no-cache"
|
|
token = caching.asset_version()
|
|
assert f'href="/assets/styles.css?v={token}"' in r.text
|
|
assert 'href="/assets/styles.css">' not in r.text
|
|
|
|
|
|
def test_middleware_buffer_failure_keeps_body_and_sets_no_cache(monkeypatch) -> None:
|
|
"""If buffering/token resolution fails, the ORIGINAL streaming body is
|
|
served unmodified — with ``no-cache`` — never an empty page."""
|
|
|
|
def _boom() -> str:
|
|
raise RuntimeError("token blew up")
|
|
|
|
monkeypatch.setattr(caching, "asset_version", _boom)
|
|
client = TestClient(_page_app())
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
assert r.headers["cache-control"] == "no-cache"
|
|
assert r.text == (
|
|
"<html><head><link rel=\"stylesheet\" href=\"/assets/styles.css\"></head></html>"
|
|
)
|
|
|
|
|
|
def test_middleware_non_html_body_on_page_path_gets_no_cache_only() -> None:
|
|
"""A page path whose response is not ``text/html`` (e.g. the 404 JSON
|
|
when the static dir is missing) is revalidated but never rewritten."""
|
|
app = FastAPI()
|
|
|
|
@app.get("/")
|
|
def not_found() -> JSONResponse:
|
|
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
|
|
|
caching.configure_caching(app)
|
|
client = TestClient(app)
|
|
r = client.get("/")
|
|
assert r.status_code == 404
|
|
assert r.headers["cache-control"] == "no-cache"
|
|
assert r.json() == {"detail": "Not Found"} # body untouched
|
|
|
|
|
|
def test_middleware_rewrite_failure_reserves_original_bytes(monkeypatch) -> None:
|
|
"""If the body buffered fine but the rewrite itself fails, the ORIGINAL
|
|
bytes are re-served (never an empty page) with ``no-cache``."""
|
|
|
|
def _boom(html: str, token: str) -> str:
|
|
raise ValueError("rewrite blew up")
|
|
|
|
monkeypatch.setattr(caching, "rewrite_asset_refs", _boom)
|
|
client = TestClient(_page_app())
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
assert r.headers["cache-control"] == "no-cache"
|
|
assert r.text == (
|
|
"<html><head><link rel=\"stylesheet\" href=\"/assets/styles.css\"></head></html>"
|
|
)
|
|
|
|
|
|
def test_middleware_leaves_api_responses_byte_identical() -> None:
|
|
"""``/api/*`` gets no injected headers at all (no cache-control)."""
|
|
client = TestClient(_page_app())
|
|
r = client.get("/api/health")
|
|
assert r.status_code == 200
|
|
assert "cache-control" not in r.headers
|
|
assert r.json() == {"status": "ok"}
|
|
|
|
|
|
def test_read_body_buffers_plain_response_bytes() -> None:
|
|
from starlette.responses import Response
|
|
|
|
resp = Response(content=b"<html>hi</html>", media_type="text/html")
|
|
assert asyncio.run(caching._read_body(resp)) == b"<html>hi</html>"
|
|
|
|
|
|
def test_read_body_copies_plain_response_memoryview() -> None:
|
|
from starlette.responses import Response
|
|
|
|
resp = Response(content=memoryview(b"<html>hi</html>"), media_type="text/html")
|
|
assert asyncio.run(caching._read_body(resp)) == b"<html>hi</html>"
|
|
|
|
|
|
def test_read_body_drains_streaming_response() -> None:
|
|
from starlette.responses import StreamingResponse
|
|
|
|
async def gen() -> AsyncIterator[bytes]:
|
|
yield b"<a>"
|
|
yield b"</a>"
|
|
|
|
resp = StreamingResponse(content=gen(), media_type="text/html")
|
|
assert asyncio.run(caching._read_body(resp)) == b"<a></a>"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 51: the dynamic share page — the ``/shared/`` prefix contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _shared_page_app() -> FastAPI:
|
|
"""A bare app with the phase-51 route pair + the middleware:
|
|
``/shared/<token>`` (``text/html`` — the page the route serves) and
|
|
``/api/shared/<token>`` (the JSON read), plus an unknown path."""
|
|
app = FastAPI()
|
|
|
|
@app.get("/shared/{token}", response_class=HTMLResponse)
|
|
def shared_page(token: str) -> str:
|
|
return (
|
|
"<html><head>"
|
|
'<link rel="stylesheet" href="/assets/styles.css">'
|
|
"</head><body>shared</body></html>"
|
|
)
|
|
|
|
@app.get("/api/shared/{token}")
|
|
def shared_api(token: str) -> JSONResponse:
|
|
return JSONResponse({"title": "Shared", "messages": []})
|
|
|
|
@app.get("/some/unknown/path")
|
|
def unknown() -> JSONResponse:
|
|
return JSONResponse({"ok": True})
|
|
|
|
caching.configure_caching(app)
|
|
return app
|
|
|
|
|
|
def test_middleware_treats_shared_page_path_as_known_html_page() -> None:
|
|
"""Phase 51: ``/shared/<uuid>`` joins the HTML_PAGES contract —
|
|
``no-cache`` + ``?v=`` asset rewrite on the ``text/html`` body (the
|
|
FileResponse body is drained by the existing ``_read_body`` path)."""
|
|
client = TestClient(_shared_page_app())
|
|
r = client.get(f"/shared/{uuid.uuid4()}")
|
|
assert r.status_code == 200
|
|
assert r.headers["cache-control"] == "no-cache"
|
|
token = caching.asset_version()
|
|
assert f'href="/assets/styles.css?v={token}"' in r.text
|
|
assert 'href="/assets/styles.css">' not in r.text
|
|
|
|
|
|
def test_middleware_leaves_api_shared_read_untouched() -> None:
|
|
"""``/api/shared/<uuid>`` — the JSON read — starts with ``/api/``,
|
|
not ``/shared/``: byte-identical pass-through, no injected headers."""
|
|
client = TestClient(_shared_page_app())
|
|
r = client.get(f"/api/shared/{uuid.uuid4()}")
|
|
assert r.status_code == 200
|
|
assert "cache-control" not in r.headers
|
|
assert r.json() == {"title": "Shared", "messages": []}
|
|
|
|
|
|
def test_middleware_leaves_unknown_paths_untouched() -> None:
|
|
"""A path that is neither a known page, ``/shared/*``, nor
|
|
``/assets/*`` passes through byte-identical, no headers."""
|
|
client = TestClient(_shared_page_app())
|
|
r = client.get("/some/unknown/path")
|
|
assert r.status_code == 200
|
|
assert "cache-control" not in r.headers
|
|
assert r.json() == {"ok": True}
|