gabriel / musehub public
test_static_assets_section63.py python
314 lines 11.9 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 6.3 — Static asset caching and compression tests.
2
3 Covers:
4 - StaticCacheMiddleware injects correct Cache-Control headers
5 - Vendor JS files include ?v= query strings in base.html
6 - static_version is a non-empty 8-char hex string (content hash)
7 - Blocking sync I/O in objects.py is wrapped in asyncio.to_thread
8 - nginx gzip config present in nginx-cf.conf
9 """
10 from __future__ import annotations
11
12 import hashlib
13 import inspect
14 import re
15 import ast
16 from pathlib import Path
17
18 import pytest
19 from musehub.muse_contracts.json_types import JSONObject
20
21
22 # ── paths ─────────────────────────────────────────────────────────────────────
23 _REPO = Path(__file__).resolve().parents[1]
24 _STATIC_DIR = _REPO / "musehub" / "templates" / "musehub" / "static"
25 _MIDDLEWARE = _REPO / "musehub" / "middleware" / "static_cache.py"
26 _TEMPLATES_PY = _REPO / "musehub" / "api" / "routes" / "musehub" / "_templates.py"
27 _BASE_HTML = _REPO / "musehub" / "templates" / "musehub" / "base.html"
28 _OBJECTS_PY = _REPO / "musehub" / "api" / "routes" / "musehub" / "objects.py"
29 _NGINX_CONF = _REPO / "deploy" / "nginx-cf.conf"
30 _MAIN_PY = _REPO / "musehub" / "main.py"
31
32
33 # ── StaticCacheMiddleware unit tests ──────────────────────────────────────────
34
35 class TestStaticCacheMiddleware:
36 """Unit-test the pure ASGI middleware without spinning up FastAPI."""
37
38 def _make_scope(self, path: str) -> JSONObject:
39 return {"type": "http", "path": path, "method": "GET", "headers": []}
40
41 async def _run(self, path: str) -> list[dict]:
42 from musehub.middleware.static_cache import StaticCacheMiddleware
43
44 messages: list[dict] = []
45
46 async def _app(scope, receive, send):
47 await send({
48 "type": "http.response.start",
49 "status": 200,
50 "headers": [],
51 })
52 await send({"type": "http.response.body", "body": b"", "more_body": False})
53
54 async def _capture(msg):
55 messages.append(msg)
56
57 async def _noop_receive():
58 return {}
59
60 mw = StaticCacheMiddleware(_app)
61 await mw(self._make_scope(path), _noop_receive, _capture)
62 return messages
63
64 @pytest.mark.asyncio
65 async def test_css_gets_far_future_cache(self):
66 # ASGI scope["path"] never includes query string
67 msgs = await self._run("/static/app.css")
68 start = next(m for m in msgs if m["type"] == "http.response.start")
69 headers = dict(start["headers"])
70 assert headers[b"cache-control"] == b"public, max-age=31536000, immutable"
71
72 @pytest.mark.asyncio
73 async def test_js_gets_far_future_cache(self):
74 msgs = await self._run("/static/app.js")
75 start = next(m for m in msgs if m["type"] == "http.response.start")
76 headers = dict(start["headers"])
77 assert headers[b"cache-control"] == b"public, max-age=31536000, immutable"
78
79 @pytest.mark.asyncio
80 async def test_map_gets_far_future_cache(self):
81 msgs = await self._run("/static/app.js.map")
82 start = next(m for m in msgs if m["type"] == "http.response.start")
83 headers = dict(start["headers"])
84 assert headers[b"cache-control"] == b"public, max-age=31536000, immutable"
85
86 @pytest.mark.asyncio
87 async def test_favicon_gets_one_day_cache(self):
88 msgs = await self._run("/static/favicon.svg")
89 start = next(m for m in msgs if m["type"] == "http.response.start")
90 headers = dict(start["headers"])
91 assert headers[b"cache-control"] == b"public, max-age=86400"
92
93 @pytest.mark.asyncio
94 async def test_png_gets_one_day_cache(self):
95 msgs = await self._run("/static/favicon-32.png")
96 start = next(m for m in msgs if m["type"] == "http.response.start")
97 headers = dict(start["headers"])
98 assert headers[b"cache-control"] == b"public, max-age=86400"
99
100 @pytest.mark.asyncio
101 async def test_non_static_path_passes_through_unmodified(self):
102 msgs = await self._run("/repos/abc/tree/main")
103 start = next(m for m in msgs if m["type"] == "http.response.start")
104 headers = dict(start["headers"])
105 assert b"cache-control" not in headers
106
107 @pytest.mark.asyncio
108 async def test_existing_cache_control_not_overwritten(self):
109 """If app already set Cache-Control, middleware must leave it alone."""
110 from musehub.middleware.static_cache import StaticCacheMiddleware
111
112 messages: list[dict] = []
113
114 async def _app_with_cc(scope, receive, send):
115 await send({
116 "type": "http.response.start",
117 "status": 200,
118 "headers": [(b"cache-control", b"no-store")],
119 })
120 await send({"type": "http.response.body", "body": b""})
121
122 async def _capture(msg):
123 messages.append(msg)
124
125 async def _noop_receive():
126 return {}
127
128 mw = StaticCacheMiddleware(_app_with_cc)
129 await mw(self._make_scope("/static/app.css"), _noop_receive, _capture)
130
131 start = next(m for m in messages if m["type"] == "http.response.start")
132 cc_values = [v for k, v in start["headers"] if k == b"cache-control"]
133 # exactly one value and it is the app's, not ours
134 assert cc_values == [b"no-store"]
135
136 @pytest.mark.asyncio
137 async def test_websocket_scope_passes_through(self):
138 """Non-HTTP scope must not be touched."""
139 from musehub.middleware.static_cache import StaticCacheMiddleware
140
141 called = []
142
143 async def _app(scope, receive, send):
144 called.append(scope["type"])
145
146 async def _noop_receive():
147 return {}
148
149 async def _noop_send(msg):
150 pass
151
152 mw = StaticCacheMiddleware(_app)
153 await mw({"type": "websocket", "path": "/static/app.css"}, _noop_receive, _noop_send)
154 assert called == ["websocket"]
155
156
157 # ── base.html vendor versioning ───────────────────────────────────────────────
158
159 class TestBaseHtmlVersioning:
160 _src = _BASE_HTML.read_text()
161
162 def test_alpinejs_has_version_param(self):
163 assert re.search(r"alpinejs\.min\.js\?v=\{\{", self._src)
164
165 def test_htmx_has_version_param(self):
166 assert re.search(r"htmx\.min\.js\?v=\{\{", self._src)
167
168 def test_json_enc_has_version_param(self):
169 assert re.search(r"json-enc\.js\?v=\{\{", self._src)
170
171 def test_response_targets_has_version_param(self):
172 assert re.search(r"response-targets\.js\?v=\{\{", self._src)
173
174 def test_app_css_has_version_param(self):
175 assert re.search(r"app\.css\?v=\{\{", self._src)
176
177 def test_app_js_has_version_param(self):
178 assert re.search(r"app\.js\?v=\{\{", self._src)
179
180
181 # ── static_version content hash ───────────────────────────────────────────────
182
183 class TestStaticVersion:
184 def test_static_version_is_8_hex_chars_when_assets_exist(self, tmp_path):
185 """_compute_static_version returns 8 lowercase hex chars when CSS+JS present."""
186 css = tmp_path / "app.css"
187 js = tmp_path / "app.js"
188 css.write_bytes(b"body{color:red}")
189 js.write_bytes(b"console.log(1)")
190
191 import hashlib
192 h = hashlib.sha256()
193 h.update(css.read_bytes())
194 h.update(js.read_bytes())
195 expected = h.hexdigest()[:8]
196
197 # Not the empty-input sentinel
198 assert expected != "e3b0c442"
199 assert re.fullmatch(r"[0-9a-f]{8}", expected)
200
201 def test_static_version_falls_back_to_cache_id_when_assets_missing(self, tmp_path, monkeypatch):
202 """When app.css and app.js are absent, falls back to .cache-id."""
203 import importlib
204 import musehub.api.routes.musehub._templates as tpl_mod
205
206 # Patch the static dir to tmp_path (no CSS/JS)
207 cache_id = tmp_path / ".cache-id"
208 cache_id.write_text("cafebabe")
209
210 monkeypatch.setattr(tpl_mod, "_STATIC_DIR", tmp_path)
211 monkeypatch.setattr(tpl_mod, "_cache_id_path", cache_id)
212
213 result = tpl_mod._compute_static_version()
214 assert result == "cafebabe"
215
216 def test_static_version_empty_when_nothing_available(self, tmp_path, monkeypatch):
217 import musehub.api.routes.musehub._templates as tpl_mod
218
219 monkeypatch.setattr(tpl_mod, "_STATIC_DIR", tmp_path)
220 monkeypatch.setattr(tpl_mod, "_cache_id_path", tmp_path / ".cache-id")
221
222 result = tpl_mod._compute_static_version()
223 assert result == ""
224
225
226 # ── objects.py: no blocking sync I/O ─────────────────────────────────────────
227
228 class TestObjectsNoBlockingIO:
229 _src = _OBJECTS_PY.read_text()
230
231 def test_asyncio_imported(self):
232 assert "import asyncio" in self._src
233
234 def test_open_wrapped_in_to_thread(self):
235 """Any open() call inside an async function must be inside asyncio.to_thread."""
236 # Check that asyncio.to_thread appears and a bare open() call outside a nested
237 # def/lambda does not appear as a direct await.
238 assert "asyncio.to_thread" in self._src
239
240 def test_no_bare_open_in_async_handler(self):
241 """
242 Parse the AST to ensure no `open(...)` call appears as a direct statement
243 (not inside a nested sync function) within an async function body.
244 """
245 tree = ast.parse(self._src)
246
247 violations: list[int] = []
248
249 class _Visitor(ast.NodeVisitor):
250 def __init__(self):
251 self._in_async = 0
252 self._in_sync_nested = 0
253
254 def visit_AsyncFunctionDef(self, node):
255 self._in_async += 1
256 self.generic_visit(node)
257 self._in_async -= 1
258
259 def visit_FunctionDef(self, node):
260 if self._in_async > 0:
261 self._in_sync_nested += 1
262 self.generic_visit(node)
263 self._in_sync_nested -= 1
264 else:
265 self.generic_visit(node)
266
267 def visit_Call(self, node):
268 if (
269 self._in_async > 0
270 and self._in_sync_nested == 0
271 and isinstance(node.func, ast.Name)
272 and node.func.id == "open"
273 ):
274 violations.append(node.lineno)
275 self.generic_visit(node)
276
277 _Visitor().visit(tree)
278 assert violations == [], f"Bare open() in async handler at lines {violations}"
279
280
281 # ── nginx gzip config ─────────────────────────────────────────────────────────
282
283 class TestNginxGzip:
284 _src = _NGINX_CONF.read_text()
285
286 def test_gzip_on(self):
287 assert re.search(r"^\s*gzip\s+on\s*;", self._src, re.MULTILINE)
288
289 def test_gzip_comp_level(self):
290 assert re.search(r"gzip_comp_level\s+[1-9]", self._src)
291
292 def test_gzip_types_includes_css(self):
293 assert "text/css" in self._src
294
295 def test_gzip_types_includes_js(self):
296 assert re.search(r"(text/javascript|application/javascript)", self._src)
297
298 def test_gzip_types_includes_json(self):
299 assert "application/json" in self._src
300
301 def test_gzip_vary_on(self):
302 assert re.search(r"gzip_vary\s+on\s*;", self._src)
303
304
305 # ── StaticCacheMiddleware registered in main.py ───────────────────────────────
306
307 class TestMiddlewareRegistration:
308 _src = _MAIN_PY.read_text()
309
310 def test_static_cache_middleware_imported(self):
311 assert "StaticCacheMiddleware" in self._src
312
313 def test_static_cache_middleware_added(self):
314 assert "add_middleware(StaticCacheMiddleware)" in self._src
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago