"""Section 6.3 — Static asset caching and compression tests. Covers: - StaticCacheMiddleware injects correct Cache-Control headers - Vendor JS files include ?v= query strings in base.html - static_version is a non-empty 8-char hex string (content hash) - Blocking sync I/O in objects.py is wrapped in asyncio.to_thread - nginx gzip config present in nginx-cf.conf """ from __future__ import annotations import ast import inspect import re from pathlib import Path import pytest from muse.core.types import blob_id, split_id from starlette.types import Receive, Scope, Send from musehub.types.json_types import JSONObject # ── paths ───────────────────────────────────────────────────────────────────── _REPO = Path(__file__).resolve().parents[1] _STATIC_DIR = _REPO / "musehub" / "templates" / "musehub" / "static" _MIDDLEWARE = _REPO / "musehub" / "middleware" / "static_cache.py" _TEMPLATES_PY = _REPO / "musehub" / "api" / "routes" / "musehub" / "_templates.py" _BASE_HTML = _REPO / "musehub" / "templates" / "musehub" / "base.html" _OBJECTS_PY = _REPO / "musehub" / "api" / "routes" / "musehub" / "objects.py" _NGINX_CONF = _REPO / "deploy" / "nginx-cf.conf" _MAIN_PY = _REPO / "musehub" / "main.py" # ── StaticCacheMiddleware unit tests ────────────────────────────────────────── class TestStaticCacheMiddleware: """Unit-test the pure ASGI middleware without spinning up FastAPI.""" def _make_scope(self, path: str) -> JSONObject: return {"type": "http", "path": path, "method": "GET", "headers": []} async def _run(self, path: str) -> list[dict]: from musehub.middleware.static_cache import StaticCacheMiddleware messages: list[dict] = [] async def _app(scope: Scope, receive: Receive, send: Send) -> None: await send({ "type": "http.response.start", "status": 200, "headers": [], }) await send({"type": "http.response.body", "body": b"", "more_body": False}) async def _capture(msg: JSONObject) -> None: messages.append(msg) async def _noop_receive() -> None: return {} mw = StaticCacheMiddleware(_app) await mw(self._make_scope(path), _noop_receive, _capture) return messages @pytest.mark.asyncio async def test_css_gets_far_future_cache(self) -> None: # ASGI scope["path"] never includes query string msgs = await self._run("/static/app.css") start = next(m for m in msgs if m["type"] == "http.response.start") headers = dict(start["headers"]) assert headers[b"cache-control"] == b"public, max-age=31536000, immutable" @pytest.mark.asyncio async def test_js_gets_far_future_cache(self) -> None: msgs = await self._run("/static/app.js") start = next(m for m in msgs if m["type"] == "http.response.start") headers = dict(start["headers"]) assert headers[b"cache-control"] == b"public, max-age=31536000, immutable" @pytest.mark.asyncio async def test_map_gets_far_future_cache(self) -> None: msgs = await self._run("/static/app.js.map") start = next(m for m in msgs if m["type"] == "http.response.start") headers = dict(start["headers"]) assert headers[b"cache-control"] == b"public, max-age=31536000, immutable" @pytest.mark.asyncio async def test_favicon_gets_one_day_cache(self) -> None: msgs = await self._run("/static/favicon.svg") start = next(m for m in msgs if m["type"] == "http.response.start") headers = dict(start["headers"]) assert headers[b"cache-control"] == b"public, max-age=86400" @pytest.mark.asyncio async def test_png_gets_one_day_cache(self) -> None: msgs = await self._run("/static/favicon-32.png") start = next(m for m in msgs if m["type"] == "http.response.start") headers = dict(start["headers"]) assert headers[b"cache-control"] == b"public, max-age=86400" @pytest.mark.asyncio async def test_non_static_path_passes_through_unmodified(self) -> None: msgs = await self._run("/repos/abc/tree/main") start = next(m for m in msgs if m["type"] == "http.response.start") headers = dict(start["headers"]) assert b"cache-control" not in headers @pytest.mark.asyncio async def test_existing_cache_control_not_overwritten(self) -> None: """If app already set Cache-Control, middleware must leave it alone.""" from musehub.middleware.static_cache import StaticCacheMiddleware messages: list[dict] = [] async def _app_with_cc(scope: Scope, receive: Receive, send: Send) -> None: await send({ "type": "http.response.start", "status": 200, "headers": [(b"cache-control", b"no-store")], }) await send({"type": "http.response.body", "body": b""}) async def _capture(msg: JSONObject) -> None: messages.append(msg) async def _noop_receive() -> None: return {} mw = StaticCacheMiddleware(_app_with_cc) await mw(self._make_scope("/static/app.css"), _noop_receive, _capture) start = next(m for m in messages if m["type"] == "http.response.start") cc_values = [v for k, v in start["headers"] if k == b"cache-control"] # exactly one value and it is the app's, not ours assert cc_values == [b"no-store"] @pytest.mark.asyncio async def test_websocket_scope_passes_through(self) -> None: """Non-HTTP scope must not be touched.""" from musehub.middleware.static_cache import StaticCacheMiddleware called = [] async def _app(scope: Scope, receive: Receive, send: Send) -> None: called.append(scope["type"]) async def _noop_receive() -> None: return {} async def _noop_send(msg: JSONObject) -> None: pass mw = StaticCacheMiddleware(_app) await mw({"type": "websocket", "path": "/static/app.css"}, _noop_receive, _noop_send) assert called == ["websocket"] # ── base.html vendor versioning ─────────────────────────────────────────────── class TestBaseHtmlVersioning: _src = _BASE_HTML.read_text() def test_alpinejs_has_version_param(self) -> None: assert re.search(r"alpinejs\.min\.js\?v=\{\{", self._src) def test_htmx_has_version_param(self) -> None: assert re.search(r"htmx\.min\.js\?v=\{\{", self._src) def test_json_enc_has_version_param(self) -> None: assert re.search(r"json-enc\.js\?v=\{\{", self._src) def test_response_targets_has_version_param(self) -> None: assert re.search(r"response-targets\.js\?v=\{\{", self._src) def test_app_css_has_version_param(self) -> None: assert re.search(r"app\.css\?v=\{\{", self._src) def test_app_js_has_version_param(self) -> None: assert re.search(r"app\.js\?v=\{\{", self._src) # ── static_version content hash ─────────────────────────────────────────────── class TestStaticVersion: def test_static_version_is_8_hex_chars_when_assets_exist(self, tmp_path: pathlib.Path) -> None: """_compute_static_version returns 8 lowercase hex chars when CSS+JS present.""" css = tmp_path / "app.css" js = tmp_path / "app.js" css.write_bytes(b"body{color:red}") js.write_bytes(b"console.log(1)") combined = css.read_bytes() + js.read_bytes() expected = split_id(blob_id(combined))[1][:8] # Not the empty-input sentinel assert expected != "e3b0c442" assert re.fullmatch(r"[0-9a-f]{8}", expected) def test_static_version_falls_back_to_cache_id_when_assets_missing(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: """When app.css and app.js are absent, falls back to .cache-id.""" import importlib import musehub.api.routes.musehub._templates as tpl_mod # Patch the static dir to tmp_path (no CSS/JS) cache_id = tmp_path / ".cache-id" cache_id.write_text("cafebabe") monkeypatch.setattr(tpl_mod, "_STATIC_DIR", tmp_path) monkeypatch.setattr(tpl_mod, "_cache_id_path", cache_id) result = tpl_mod._compute_static_version() assert result == "cafebabe" def test_static_version_empty_when_nothing_available(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: import musehub.api.routes.musehub._templates as tpl_mod monkeypatch.setattr(tpl_mod, "_STATIC_DIR", tmp_path) monkeypatch.setattr(tpl_mod, "_cache_id_path", tmp_path / ".cache-id") result = tpl_mod._compute_static_version() assert result == "" # ── objects.py: no blocking sync I/O ───────────────────────────────────────── class TestObjectsNoBlockingIO: _src = _OBJECTS_PY.read_text() def test_no_bare_open_in_async_handler(self) -> None: """ Parse the AST to ensure no `open(...)` call appears as a direct statement (not inside a nested sync function) within an async function body. """ tree = ast.parse(self._src) violations: list[int] = [] class _Visitor(ast.NodeVisitor): def __init__(self) -> None: self._in_async = 0 self._in_sync_nested = 0 def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: self._in_async += 1 self.generic_visit(node) self._in_async -= 1 def visit_FunctionDef(self, node: ast.FunctionDef) -> None: if self._in_async > 0: self._in_sync_nested += 1 self.generic_visit(node) self._in_sync_nested -= 1 else: self.generic_visit(node) def visit_Call(self, node: ast.Call) -> None: if ( self._in_async > 0 and self._in_sync_nested == 0 and isinstance(node.func, ast.Name) and node.func.id == "open" ): violations.append(node.lineno) self.generic_visit(node) _Visitor().visit(tree) assert violations == [], f"Bare open() in async handler at lines {violations}" # ── nginx gzip config ───────────────────────────────────────────────────────── class TestNginxGzip: _src = _NGINX_CONF.read_text() def test_gzip_on(self) -> None: assert re.search(r"^\s*gzip\s+on\s*;", self._src, re.MULTILINE) def test_gzip_comp_level(self) -> None: assert re.search(r"gzip_comp_level\s+[1-9]", self._src) def test_gzip_types_includes_css(self) -> None: assert "text/css" in self._src def test_gzip_types_includes_js(self) -> None: assert re.search(r"(text/javascript|application/javascript)", self._src) def test_gzip_types_includes_json(self) -> None: assert "application/json" in self._src def test_gzip_vary_on(self) -> None: assert re.search(r"gzip_vary\s+on\s*;", self._src) # ── StaticCacheMiddleware registered in main.py ─────────────────────────────── class TestMiddlewareRegistration: _src = _MAIN_PY.read_text() def test_static_cache_middleware_imported(self) -> None: assert "StaticCacheMiddleware" in self._src def test_static_cache_middleware_added(self) -> None: assert "add_middleware(StaticCacheMiddleware)" in self._src