"""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 hashlib import inspect import re import ast from pathlib import Path import pytest from musehub.muse_contracts.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, receive, send): await send({ "type": "http.response.start", "status": 200, "headers": [], }) await send({"type": "http.response.body", "body": b"", "more_body": False}) async def _capture(msg): messages.append(msg) async def _noop_receive(): 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): # 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): 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): 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): 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): 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): 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): """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, receive, send): 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): messages.append(msg) async def _noop_receive(): 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): """Non-HTTP scope must not be touched.""" from musehub.middleware.static_cache import StaticCacheMiddleware called = [] async def _app(scope, receive, send): called.append(scope["type"]) async def _noop_receive(): return {} async def _noop_send(msg): 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): assert re.search(r"alpinejs\.min\.js\?v=\{\{", self._src) def test_htmx_has_version_param(self): assert re.search(r"htmx\.min\.js\?v=\{\{", self._src) def test_json_enc_has_version_param(self): assert re.search(r"json-enc\.js\?v=\{\{", self._src) def test_response_targets_has_version_param(self): assert re.search(r"response-targets\.js\?v=\{\{", self._src) def test_app_css_has_version_param(self): assert re.search(r"app\.css\?v=\{\{", self._src) def test_app_js_has_version_param(self): 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): """_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)") import hashlib h = hashlib.sha256() h.update(css.read_bytes()) h.update(js.read_bytes()) expected = h.hexdigest()[: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, monkeypatch): """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, monkeypatch): 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_asyncio_imported(self): assert "import asyncio" in self._src def test_open_wrapped_in_to_thread(self): """Any open() call inside an async function must be inside asyncio.to_thread.""" # Check that asyncio.to_thread appears and a bare open() call outside a nested # def/lambda does not appear as a direct await. assert "asyncio.to_thread" in self._src def test_no_bare_open_in_async_handler(self): """ 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): self._in_async = 0 self._in_sync_nested = 0 def visit_AsyncFunctionDef(self, node): self._in_async += 1 self.generic_visit(node) self._in_async -= 1 def visit_FunctionDef(self, node): 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): 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): assert re.search(r"^\s*gzip\s+on\s*;", self._src, re.MULTILINE) def test_gzip_comp_level(self): assert re.search(r"gzip_comp_level\s+[1-9]", self._src) def test_gzip_types_includes_css(self): assert "text/css" in self._src def test_gzip_types_includes_js(self): assert re.search(r"(text/javascript|application/javascript)", self._src) def test_gzip_types_includes_json(self): assert "application/json" in self._src def test_gzip_vary_on(self): 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): assert "StaticCacheMiddleware" in self._src def test_static_cache_middleware_added(self): assert "add_middleware(StaticCacheMiddleware)" in self._src