gabriel / musehub public
test_musehub_ui_graph_blob_ssr.py python
383 lines 11.5 KB
c0f0b481 release: merge dev → main (#5) 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.__graphData 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.__graphData" 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 # "2 commits" should appear in the SSR metadata span
158 assert "2 commits" 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 assert "2 branches" in response.text
175
176
177 # ---------------------------------------------------------------------------
178 # Blob page tests
179 # ---------------------------------------------------------------------------
180
181
182 @pytest.mark.anyio
183 async def test_blob_page_renders_file_content_server_side(
184 client: AsyncClient,
185 db_session: AsyncSession,
186 ) -> None:
187 """Text file content is rendered in the initial HTML without JS.
188
189 The line-numbered table must be present in the server response body so
190 non-JS clients can read file content.
191 """
192 import tempfile as _tempfile
193
194 repo_id = await _seed_repo(db_session)
195
196 # Write a real file so blob_page() can read its content.
197 disk = _tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False)
198 disk.write("print('hello')\n")
199 disk.close()
200
201 try:
202 await _seed_object(
203 db_session,
204 repo_id,
205 path="main.py",
206 disk_path=disk.name,
207 size_bytes=16,
208 )
209
210 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/main.py")
211 assert response.status_code == 200
212 body = response.text
213 # File content must appear in the SSR HTML (inside the line table)
214 assert "print" in body
215 assert "hello" in body
216 finally:
217 os.unlink(disk.name)
218
219
220 @pytest.mark.anyio
221 async def test_blob_page_renders_line_numbers(
222 client: AsyncClient,
223 db_session: AsyncSession,
224 ) -> None:
225 """Line number anchors (#L1) are present in the server-rendered table.
226
227 Allows direct linking to individual lines without JavaScript.
228 """
229 import tempfile as _tempfile
230
231 repo_id = await _seed_repo(db_session)
232
233 disk = _tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False)
234 disk.write("line one\nline two\n")
235 disk.close()
236
237 try:
238 await _seed_object(
239 db_session,
240 repo_id,
241 path="code.py",
242 disk_path=disk.name,
243 size_bytes=18,
244 )
245
246 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/code.py")
247 assert response.status_code == 200
248 body = response.text
249 assert 'id="L1"' in body
250 assert 'href="#L1"' in body
251 finally:
252 os.unlink(disk.name)
253
254
255 @pytest.mark.anyio
256 async def test_blob_page_shows_file_size(
257 client: AsyncClient,
258 db_session: AsyncSession,
259 ) -> None:
260 """File size appears in the server-rendered blob header.
261
262 Users can see the file size immediately without waiting for the JS fetch.
263 """
264 import tempfile as _tempfile
265
266 repo_id = await _seed_repo(db_session)
267
268 disk = _tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
269 disk.write("a" * 2048)
270 disk.close()
271
272 try:
273 await _seed_object(
274 db_session,
275 repo_id,
276 path="readme.txt",
277 disk_path=disk.name,
278 size_bytes=2048,
279 )
280
281 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/readme.txt")
282 assert response.status_code == 200
283 # filesizeformat renders 2048 bytes as "2.0 KB"
284 assert "2.0 KB" in response.text
285 finally:
286 os.unlink(disk.name)
287
288
289 @pytest.mark.anyio
290 async def test_blob_page_binary_shows_download_link(
291 client: AsyncClient,
292 db_session: AsyncSession,
293 ) -> None:
294 """Binary files show a download link instead of a line-numbered table.
295
296 The download link is server-rendered so non-JS clients can retrieve the
297 file even when the hex-dump JS renderer is unavailable.
298 """
299 import tempfile as _tempfile
300
301 repo_id = await _seed_repo(db_session)
302
303 disk = _tempfile.NamedTemporaryFile(mode="wb", suffix=".webp", delete=False)
304 disk.write(b"\x00\x01\x02\x03")
305 disk.close()
306
307 try:
308 await _seed_object(
309 db_session,
310 repo_id,
311 path="image.webp",
312 disk_path=disk.name,
313 size_bytes=4,
314 )
315
316 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/image.webp")
317 assert response.status_code == 200
318 body = response.text
319 # SSR renders binary download link, no line table
320 assert "Download raw" in body or "download" in body.lower()
321 assert 'id="L1"' not in body
322 finally:
323 os.unlink(disk.name)
324
325
326 @pytest.mark.anyio
327 async def test_blob_page_midi_shows_player_shell(
328 client: AsyncClient,
329 db_session: AsyncSession,
330 ) -> None:
331 """MIDI files render a player shell with data-midi-url set server-side.
332
333 The ``#midi-player`` div and its ``data-midi-url`` attribute must be
334 present in the initial HTML so a JS MIDI player can attach without an
335 extra API call to discover the raw URL.
336 """
337 import tempfile as _tempfile
338
339 repo_id = await _seed_repo(db_session)
340
341 disk = _tempfile.NamedTemporaryFile(mode="wb", suffix=".mid", delete=False)
342 disk.write(b"MThd")
343 disk.close()
344
345 try:
346 await _seed_object(
347 db_session,
348 repo_id,
349 path="track.mid",
350 disk_path=disk.name,
351 size_bytes=4,
352 )
353
354 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/track.mid")
355 assert response.status_code == 200
356 body = response.text
357 assert "midi-player" in body
358 assert "data-midi-url" in body
359 finally:
360 os.unlink(disk.name)
361
362
363 @pytest.mark.anyio
364 async def test_blob_page_unknown_path_no_ssr(
365 client: AsyncClient,
366 db_session: AsyncSession,
367 ) -> None:
368 """A path with no matching object returns 200 with blob_found=false.
369
370 The page shell renders but no SSR blob content is present — the JS
371 fallback fetches metadata and shows an appropriate error. We do NOT
372 raise a 404 at the UI layer so that the page chrome (nav, breadcrumb)
373 stays intact for the user.
374 """
375 await _seed_repo(db_session)
376
377 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/nonexistent.py")
378 assert response.status_code == 200
379 # No SSR blob content block when object is absent — the id="blob-ssr-content" div
380 # must NOT appear (note: the string 'blob-ssr-content' may appear in JS code).
381 assert 'id="blob-ssr-content"' not in response.text
382 # JS guard variable must be set to false so the JS fallback runs
383 assert "ssrBlobRendered = false" in response.text