"""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 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", "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" # --------------------------------------------------------------------------- # rewrite_asset_refs (task 02) # --------------------------------------------------------------------------- def test_rewrite_versions_href_with_leading_slash() -> None: html = '' assert rewrite_asset_refs(html, TOKEN) == ( '' ) def test_rewrite_versions_src_without_leading_slash() -> None: html = '' assert rewrite_asset_refs(html, TOKEN) == ( '' ) def test_rewrite_versions_module_script_src() -> None: html = '' assert rewrite_asset_refs(html, TOKEN) == ( '' ) def test_rewrite_versions_every_ref_in_one_pass() -> None: html = ( '' "" '' ) assert rewrite_asset_refs(html, TOKEN) == ( '' '' '' ) def test_rewrite_is_idempotent_for_already_versioned_refs() -> None: html = '' assert rewrite_asset_refs(html, "deadbeef") == html def test_rewrite_leaves_query_and_fragment_refs_alone() -> None: html = '' assert rewrite_asset_refs(html, TOKEN) == html def test_rewrite_leaves_non_asset_refs_untouched() -> None: html = ( 'sources' '' 'login' ) assert rewrite_asset_refs(html, TOKEN) == html def test_rewrite_returns_html_unchanged_when_no_asset_refs() -> None: html = "

no assets here

" 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 '' @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 == ( "" ) 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 == ( "" ) 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"hi", media_type="text/html") assert asyncio.run(caching._read_body(resp)) == b"hi" def test_read_body_copies_plain_response_memoryview() -> None: from starlette.responses import Response resp = Response(content=memoryview(b"hi"), media_type="text/html") assert asyncio.run(caching._read_body(resp)) == b"hi" def test_read_body_drains_streaming_response() -> None: from starlette.responses import StreamingResponse async def gen() -> AsyncIterator[bytes]: yield b"" yield b"" resp = StreamingResponse(content=gen(), media_type="text/html") assert asyncio.run(caching._read_body(resp)) == b""