gabriel / musehub public
test_musehub_ui_graph_blob_ssr.py python
384 lines 11.6 KB
04faf0e3 feat: supercharge all repo pages, enforce separation of concerns Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """SSR tests for the graph (DAG) page and blob viewer — issue #584.
2
3 Verifies that:
4 - graph_page() injects ``window.__graphData`` and renders commit/branch counts server-side.
5 - blob_page() renders text file content (line-numbered table) server-side.
6 - blob_page() renders MIDI player shell with data-midi-url.
7 - blob_page() renders binary download link when file is binary.
8 - blob_page() returns 200 with blob_found=False context when object is absent (not 404,
9 since the page itself is valid — the JS fallback handles missing files).
10
11 Covers:
12 - test_graph_page_sets_graph_data_js_global
13 - test_graph_page_shows_commit_count
14 - test_blob_page_renders_file_content_server_side
15 - test_blob_page_renders_line_numbers
16 - test_blob_page_shows_file_size
17 - test_blob_page_binary_shows_download_link
18 - test_blob_page_midi_shows_player_shell
19 - test_blob_page_unknown_path_no_ssr
20 """
21 from __future__ import annotations
22
23 import os
24 import uuid
25 from datetime import datetime, timezone
26
27 import pytest
28 from httpx import AsyncClient
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubObject, MusehubRepo
32
33 # ---------------------------------------------------------------------------
34 # Constants
35 # ---------------------------------------------------------------------------
36
37 _OWNER = "graphblobssr584"
38 _SLUG = "graph-blob-ssr-584"
39
40 # ---------------------------------------------------------------------------
41 # Seed helpers
42 # ---------------------------------------------------------------------------
43
44
45 async def _seed_repo(db: AsyncSession) -> str:
46 """Seed a public repo and return its repo_id string."""
47 repo = MusehubRepo(
48 repo_id=str(uuid.uuid4()),
49 name=_SLUG,
50 owner=_OWNER,
51 slug=_SLUG,
52 visibility="public",
53 owner_user_id=str(uuid.uuid4()),
54 )
55 db.add(repo)
56 await db.flush()
57 return str(repo.repo_id)
58
59
60 async def _seed_commit(
61 db: AsyncSession,
62 repo_id: str,
63 *,
64 commit_id: str | None = None,
65 author: str = "alice",
66 message: str = "Initial commit",
67 branch: str = "main",
68 ) -> str:
69 """Seed a commit row and return the commit_id."""
70 cid = commit_id or (uuid.uuid4().hex + uuid.uuid4().hex)[:40]
71 db.add(
72 MusehubCommit(
73 commit_id=cid,
74 repo_id=repo_id,
75 branch=branch,
76 parent_ids=[],
77 message=message,
78 author=author,
79 timestamp=datetime.now(timezone.utc),
80 snapshot_id=None,
81 )
82 )
83 await db.flush()
84 return cid
85
86
87 async def _seed_branch(db: AsyncSession, repo_id: str, head_id: str, name: str = "main") -> None:
88 """Seed a branch row."""
89 db.add(MusehubBranch(repo_id=repo_id, name=name, head_commit_id=head_id))
90 await db.flush()
91
92
93 async def _seed_object(
94 db: AsyncSession,
95 repo_id: str,
96 *,
97 path: str,
98 disk_path: str,
99 size_bytes: int = 0,
100 ) -> str:
101 """Seed a MusehubObject row and return its object_id."""
102 oid = "sha256:" + uuid.uuid4().hex
103 db.add(
104 MusehubObject(
105 object_id=oid,
106 repo_id=repo_id,
107 path=path,
108 size_bytes=size_bytes,
109 disk_path=disk_path,
110 )
111 )
112 await db.flush()
113 return oid
114
115
116 # ---------------------------------------------------------------------------
117 # Graph page tests
118 # ---------------------------------------------------------------------------
119
120
121 @pytest.mark.anyio
122 async def test_graph_page_sets_graph_data_js_global(
123 client: AsyncClient,
124 db_session: AsyncSession,
125 ) -> None:
126 """window.__graphCfg is injected into the graph page HTML server-side.
127
128 The DAG renderer reads this global on load to skip an extra API round-trip.
129 Its presence in the initial HTML is the SSR contract for this page.
130 """
131 repo_id = await _seed_repo(db_session)
132 cid = await _seed_commit(db_session, repo_id)
133 await _seed_branch(db_session, repo_id, cid)
134
135 response = await client.get(f"/{_OWNER}/{_SLUG}/graph")
136 assert response.status_code == 200
137 assert "window.__graphCfg" in response.text
138
139
140 @pytest.mark.anyio
141 async def test_graph_page_shows_commit_count(
142 client: AsyncClient,
143 db_session: AsyncSession,
144 ) -> None:
145 """Commit count appears in the server-rendered HTML header.
146
147 The count must be visible before JavaScript runs so users and crawlers see
148 accurate metadata.
149 """
150 repo_id = await _seed_repo(db_session)
151 cid1 = await _seed_commit(db_session, repo_id, message="First commit")
152 cid2 = await _seed_commit(db_session, repo_id, message="Second commit")
153 await _seed_branch(db_session, repo_id, cid2)
154
155 response = await client.get(f"/{_OWNER}/{_SLUG}/graph")
156 assert response.status_code == 200
157 # Commit count is rendered server-side as a number in the stat span
158 assert "graph-stat-value" in response.text
159
160
161 @pytest.mark.anyio
162 async def test_graph_page_shows_branch_count(
163 client: AsyncClient,
164 db_session: AsyncSession,
165 ) -> None:
166 """Branch count appears in the server-rendered HTML header."""
167 repo_id = await _seed_repo(db_session)
168 cid = await _seed_commit(db_session, repo_id)
169 await _seed_branch(db_session, repo_id, cid, name="main")
170 await _seed_branch(db_session, repo_id, cid, name="feat/jazz")
171
172 response = await client.get(f"/{_OWNER}/{_SLUG}/graph")
173 assert response.status_code == 200
174 # Branch count is rendered server-side in the graph stats bar
175 assert "graph-stat-value" in response.text
176
177
178 # ---------------------------------------------------------------------------
179 # Blob page tests
180 # ---------------------------------------------------------------------------
181
182
183 @pytest.mark.anyio
184 async def test_blob_page_renders_file_content_server_side(
185 client: AsyncClient,
186 db_session: AsyncSession,
187 ) -> None:
188 """Text file content is rendered in the initial HTML without JS.
189
190 The line-numbered table must be present in the server response body so
191 non-JS clients can read file content.
192 """
193 import tempfile as _tempfile
194
195 repo_id = await _seed_repo(db_session)
196
197 # Write a real file so blob_page() can read its content.
198 disk = _tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False)
199 disk.write("print('hello')\n")
200 disk.close()
201
202 try:
203 await _seed_object(
204 db_session,
205 repo_id,
206 path="main.py",
207 disk_path=disk.name,
208 size_bytes=16,
209 )
210
211 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/main.py")
212 assert response.status_code == 200
213 body = response.text
214 # File content must appear in the SSR HTML (inside the line table)
215 assert "print" in body
216 assert "hello" in body
217 finally:
218 os.unlink(disk.name)
219
220
221 @pytest.mark.anyio
222 async def test_blob_page_renders_line_numbers(
223 client: AsyncClient,
224 db_session: AsyncSession,
225 ) -> None:
226 """Line number anchors (#L1) are present in the server-rendered table.
227
228 Allows direct linking to individual lines without JavaScript.
229 """
230 import tempfile as _tempfile
231
232 repo_id = await _seed_repo(db_session)
233
234 disk = _tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False)
235 disk.write("line one\nline two\n")
236 disk.close()
237
238 try:
239 await _seed_object(
240 db_session,
241 repo_id,
242 path="code.py",
243 disk_path=disk.name,
244 size_bytes=18,
245 )
246
247 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/code.py")
248 assert response.status_code == 200
249 body = response.text
250 assert 'id="L1"' in body
251 assert 'href="#L1"' in body
252 finally:
253 os.unlink(disk.name)
254
255
256 @pytest.mark.anyio
257 async def test_blob_page_shows_file_size(
258 client: AsyncClient,
259 db_session: AsyncSession,
260 ) -> None:
261 """File size appears in the server-rendered blob header.
262
263 Users can see the file size immediately without waiting for the JS fetch.
264 """
265 import tempfile as _tempfile
266
267 repo_id = await _seed_repo(db_session)
268
269 disk = _tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
270 disk.write("a" * 2048)
271 disk.close()
272
273 try:
274 await _seed_object(
275 db_session,
276 repo_id,
277 path="readme.txt",
278 disk_path=disk.name,
279 size_bytes=2048,
280 )
281
282 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/readme.txt")
283 assert response.status_code == 200
284 # filesizeformat renders 2048 bytes as "2.0 KB"
285 assert "2.0 KB" in response.text
286 finally:
287 os.unlink(disk.name)
288
289
290 @pytest.mark.anyio
291 async def test_blob_page_binary_shows_download_link(
292 client: AsyncClient,
293 db_session: AsyncSession,
294 ) -> None:
295 """Binary files show a download link instead of a line-numbered table.
296
297 The download link is server-rendered so non-JS clients can retrieve the
298 file even when the hex-dump JS renderer is unavailable.
299 """
300 import tempfile as _tempfile
301
302 repo_id = await _seed_repo(db_session)
303
304 disk = _tempfile.NamedTemporaryFile(mode="wb", suffix=".webp", delete=False)
305 disk.write(b"\x00\x01\x02\x03")
306 disk.close()
307
308 try:
309 await _seed_object(
310 db_session,
311 repo_id,
312 path="image.webp",
313 disk_path=disk.name,
314 size_bytes=4,
315 )
316
317 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/image.webp")
318 assert response.status_code == 200
319 body = response.text
320 # SSR renders binary download link, no line table
321 assert "Download raw" in body or "download" in body.lower()
322 assert 'id="L1"' not in body
323 finally:
324 os.unlink(disk.name)
325
326
327 @pytest.mark.anyio
328 async def test_blob_page_midi_shows_player_shell(
329 client: AsyncClient,
330 db_session: AsyncSession,
331 ) -> None:
332 """MIDI files render a player shell with data-midi-url set server-side.
333
334 The ``#midi-player`` div and its ``data-midi-url`` attribute must be
335 present in the initial HTML so a JS MIDI player can attach without an
336 extra API call to discover the raw URL.
337 """
338 import tempfile as _tempfile
339
340 repo_id = await _seed_repo(db_session)
341
342 disk = _tempfile.NamedTemporaryFile(mode="wb", suffix=".mid", delete=False)
343 disk.write(b"MThd")
344 disk.close()
345
346 try:
347 await _seed_object(
348 db_session,
349 repo_id,
350 path="track.mid",
351 disk_path=disk.name,
352 size_bytes=4,
353 )
354
355 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/track.mid")
356 assert response.status_code == 200
357 body = response.text
358 assert "midi-player" in body
359 assert "data-midi-url" in body
360 finally:
361 os.unlink(disk.name)
362
363
364 @pytest.mark.anyio
365 async def test_blob_page_unknown_path_no_ssr(
366 client: AsyncClient,
367 db_session: AsyncSession,
368 ) -> None:
369 """A path with no matching object returns 200 with blob_found=false.
370
371 The page shell renders but no SSR blob content is present — the JS
372 fallback fetches metadata and shows an appropriate error. We do NOT
373 raise a 404 at the UI layer so that the page chrome (nav, breadcrumb)
374 stays intact for the user.
375 """
376 await _seed_repo(db_session)
377
378 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/nonexistent.py")
379 assert response.status_code == 200
380 # No SSR blob content block when object is absent — the id="blob-ssr-content" div
381 # must NOT appear (note: the string 'blob-ssr-content' may appear in JS code).
382 assert 'id="blob-ssr-content"' not in response.text
383 # JS guard variable must be set to false so the JS fallback runs
384 assert "ssrBlobRendered = false" in response.text