gabriel / musehub public
test_musehub_ui_blame.py python
381 lines 12.8 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for the Muse Hub blame UI page (SSR).
2
3 Covers:
4 - test_blame_page_renders — GET /musehub/ui/{owner}/{slug}/blame/{ref}/{path} returns 200 HTML
5 - test_blame_page_no_auth_required — page accessible without a JWT
6 - test_blame_page_unknown_repo_404 — bad owner/slug returns 404
7 - test_blame_page_contains_table_headers — HTML contains blame table column headers
8 - test_blame_page_contains_filter_bar — HTML includes track/beat filter controls
9 - test_blame_page_contains_breadcrumb — breadcrumb links owner, repo_slug, ref, and filename
10 - test_blame_page_contains_piano_roll_link — quick-link to the piano-roll page present
11 - test_blame_page_contains_commits_link — quick-link to the commit list present
12 - test_blame_json_response — Accept: application/json returns BlameResponse JSON
13 - test_blame_json_has_entries_key — JSON body contains 'entries' and 'totalEntries' keys
14 - test_blame_json_format_param — ?format=json returns JSON without Accept header
15 - test_blame_page_path_in_server_context — file path present in server-rendered HTML
16 - test_blame_page_ref_in_server_context — commit ref present in server-rendered HTML
17 - test_blame_page_server_side_render_present — page renders blame table server-side (no apiFetch for data)
18 - test_blame_page_filter_bar_track_options — track <select> lists standard instrument names
19 - test_blame_page_commit_sha_link — commit-sha class present in SSR template
20 - test_blame_page_velocity_bar_present — velocity bar element present in SSR template
21 - test_blame_page_beat_range_column — beat range column rendered server-side
22 """
23 from __future__ import annotations
24
25 import pytest
26 from httpx import AsyncClient
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from musehub.db.musehub_models import MusehubCommit, MusehubRepo
30
31
32 # ---------------------------------------------------------------------------
33 # Helpers
34 # ---------------------------------------------------------------------------
35
36
37 async def _make_repo(
38 db_session: AsyncSession,
39 *,
40 owner: str = "testuser",
41 slug: str = "test-beats",
42 visibility: str = "public",
43 ) -> str:
44 """Seed a minimal repo and return its repo_id string."""
45 repo = MusehubRepo(
46 name=slug,
47 owner=owner,
48 slug=slug,
49 visibility=visibility,
50 owner_user_id="00000000-0000-0000-0000-000000000001",
51 )
52 db_session.add(repo)
53 await db_session.commit()
54 await db_session.refresh(repo)
55 return str(repo.repo_id)
56
57
58 async def _add_commit(db_session: AsyncSession, repo_id: str) -> None:
59 """Seed a single commit so blame entries are non-empty."""
60 from datetime import datetime, timezone
61
62 commit = MusehubCommit(
63 repo_id=repo_id,
64 commit_id="abc1234567890abcdef",
65 message="Add jazz piano chords",
66 author="testuser",
67 branch="main",
68 timestamp=datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc),
69 )
70 db_session.add(commit)
71 await db_session.commit()
72
73
74 _OWNER = "testuser"
75 _SLUG = "test-beats"
76 _REF = "abc1234567890abcdef"
77 _PATH = "tracks/piano.mid"
78
79
80 # ---------------------------------------------------------------------------
81 # HTML rendering
82 # ---------------------------------------------------------------------------
83
84
85 @pytest.mark.anyio
86 async def test_blame_page_renders(
87 client: AsyncClient,
88 db_session: AsyncSession,
89 ) -> None:
90 """GET /musehub/ui/{owner}/{slug}/blame/{ref}/{path} must return 200 HTML."""
91 await _make_repo(db_session)
92 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
93 response = await client.get(url)
94 assert response.status_code == 200
95 assert "text/html" in response.headers["content-type"]
96
97
98 @pytest.mark.anyio
99 async def test_blame_page_no_auth_required(
100 client: AsyncClient,
101 db_session: AsyncSession,
102 ) -> None:
103 """Blame page must be accessible without an Authorization header."""
104 await _make_repo(db_session)
105 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
106 response = await client.get(url)
107 assert response.status_code != 401
108 assert response.status_code == 200
109
110
111 @pytest.mark.anyio
112 async def test_blame_page_unknown_repo_404(
113 client: AsyncClient,
114 db_session: AsyncSession,
115 ) -> None:
116 """Unknown owner/slug must return 404."""
117 url = f"/musehub/ui/nobody/no-repo/blame/{_REF}/{_PATH}"
118 response = await client.get(url)
119 assert response.status_code == 404
120
121
122 @pytest.mark.anyio
123 async def test_blame_page_contains_table_headers(
124 client: AsyncClient,
125 db_session: AsyncSession,
126 ) -> None:
127 """Rendered HTML must contain the blame table column headers when entries exist."""
128 repo_id = await _make_repo(db_session)
129 await _add_commit(db_session, repo_id)
130 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
131 response = await client.get(url)
132 assert response.status_code == 200
133 body = response.text
134 assert "Commit" in body
135 assert "Author" in body
136 assert "Track" in body
137 assert "Pitch" in body
138 assert "Velocity" in body
139
140
141 @pytest.mark.anyio
142 async def test_blame_page_contains_filter_bar(
143 client: AsyncClient,
144 db_session: AsyncSession,
145 ) -> None:
146 """Rendered HTML must include the track and beat-range filter controls."""
147 await _make_repo(db_session)
148 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
149 response = await client.get(url)
150 assert response.status_code == 200
151 body = response.text
152 assert "blame-track-sel" in body
153 assert "blame-beat-start" in body
154 assert "blame-beat-end" in body
155
156
157 @pytest.mark.anyio
158 async def test_blame_page_contains_breadcrumb(
159 client: AsyncClient,
160 db_session: AsyncSession,
161 ) -> None:
162 """Breadcrumb must reference owner, repo slug, ref, and filename."""
163 await _make_repo(db_session)
164 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
165 response = await client.get(url)
166 assert response.status_code == 200
167 body = response.text
168 assert _OWNER in body
169 assert _SLUG in body
170 assert _REF[:8] in body
171 assert "piano.mid" in body
172
173
174 @pytest.mark.anyio
175 async def test_blame_page_contains_piano_roll_link(
176 client: AsyncClient,
177 db_session: AsyncSession,
178 ) -> None:
179 """Page must include a quick-link to the piano-roll view for the same file."""
180 await _make_repo(db_session)
181 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
182 response = await client.get(url)
183 assert response.status_code == 200
184 body = response.text
185 assert "piano-roll" in body
186
187
188 @pytest.mark.anyio
189 async def test_blame_page_contains_commits_link(
190 client: AsyncClient,
191 db_session: AsyncSession,
192 ) -> None:
193 """Page must include a quick-link to the commits list."""
194 await _make_repo(db_session)
195 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
196 response = await client.get(url)
197 assert response.status_code == 200
198 body = response.text
199 assert "/commits" in body
200
201
202 # ---------------------------------------------------------------------------
203 # JSON content negotiation
204 # ---------------------------------------------------------------------------
205
206
207 @pytest.mark.anyio
208 async def test_blame_json_response(
209 client: AsyncClient,
210 db_session: AsyncSession,
211 ) -> None:
212 """Accept: application/json must return a JSON response (not HTML)."""
213 await _make_repo(db_session)
214 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
215 response = await client.get(url, headers={"Accept": "application/json"})
216 assert response.status_code == 200
217 assert "application/json" in response.headers["content-type"]
218
219
220 @pytest.mark.anyio
221 async def test_blame_json_has_entries_key(
222 client: AsyncClient,
223 db_session: AsyncSession,
224 ) -> None:
225 """JSON response must contain 'entries' and 'totalEntries' keys."""
226 await _make_repo(db_session)
227 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
228 response = await client.get(url, headers={"Accept": "application/json"})
229 assert response.status_code == 200
230 data = response.json()
231 assert "entries" in data
232 assert "totalEntries" in data
233 assert isinstance(data["entries"], list)
234 assert isinstance(data["totalEntries"], int)
235
236
237 @pytest.mark.anyio
238 async def test_blame_json_format_param(
239 client: AsyncClient,
240 db_session: AsyncSession,
241 ) -> None:
242 """?format=json must return JSON without an Accept header."""
243 await _make_repo(db_session)
244 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}?format=json"
245 response = await client.get(url)
246 assert response.status_code == 200
247 assert "application/json" in response.headers["content-type"]
248 data = response.json()
249 assert "entries" in data
250
251
252 # ---------------------------------------------------------------------------
253 # JS context variable injection
254 # ---------------------------------------------------------------------------
255
256
257 @pytest.mark.anyio
258 async def test_blame_page_path_in_server_context(
259 client: AsyncClient,
260 db_session: AsyncSession,
261 ) -> None:
262 """The MIDI file path must appear in the server-rendered HTML."""
263 await _make_repo(db_session)
264 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
265 response = await client.get(url)
266 assert response.status_code == 200
267 body = response.text
268 assert _PATH in body
269
270
271 @pytest.mark.anyio
272 async def test_blame_page_ref_in_server_context(
273 client: AsyncClient,
274 db_session: AsyncSession,
275 ) -> None:
276 """The commit ref (short form) must appear in the server-rendered HTML."""
277 await _make_repo(db_session)
278 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
279 response = await client.get(url)
280 assert response.status_code == 200
281 body = response.text
282 # short_ref is the first 8 chars; full ref also appears in breadcrumb links
283 assert _REF[:8] in body
284
285
286 @pytest.mark.anyio
287 async def test_blame_page_server_side_render_present(
288 client: AsyncClient,
289 db_session: AsyncSession,
290 ) -> None:
291 """Blame content must be rendered server-side — filter form and blame div in HTML."""
292 await _make_repo(db_session)
293 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
294 response = await client.get(url)
295 assert response.status_code == 200
296 body = response.text
297 # Filter form is present (server-rendered)
298 assert "blame-filter-bar" in body
299 # Table structure or empty state always rendered server-side (no loading placeholder)
300 assert "blame-header" in body
301 assert "Loading" not in body
302
303
304 # ---------------------------------------------------------------------------
305 # UI element assertions in JS template strings
306 # ---------------------------------------------------------------------------
307
308
309 @pytest.mark.anyio
310 async def test_blame_page_filter_bar_track_options(
311 client: AsyncClient,
312 db_session: AsyncSession,
313 ) -> None:
314 """Track <select> must list standard instrument track names."""
315 await _make_repo(db_session)
316 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
317 response = await client.get(url)
318 assert response.status_code == 200
319 body = response.text
320 for instrument in ("piano", "bass", "drums", "keys"):
321 assert instrument in body
322
323
324 @pytest.mark.anyio
325 async def test_blame_page_pitch_badge_present(
326 client: AsyncClient,
327 db_session: AsyncSession,
328 ) -> None:
329 """pitch-badge CSS class must appear in the SSR blame table."""
330 await _make_repo(db_session)
331 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
332 response = await client.get(url)
333 assert response.status_code == 200
334 body = response.text
335 assert "pitch-badge" in body
336
337
338 @pytest.mark.anyio
339 async def test_blame_page_commit_sha_link(
340 client: AsyncClient,
341 db_session: AsyncSession,
342 ) -> None:
343 """commit-sha CSS class must appear in the server-rendered blame table."""
344 await _make_repo(db_session)
345 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
346 response = await client.get(url)
347 assert response.status_code == 200
348 body = response.text
349 assert "commit-sha" in body
350
351
352 @pytest.mark.anyio
353 async def test_blame_page_velocity_bar_present(
354 client: AsyncClient,
355 db_session: AsyncSession,
356 ) -> None:
357 """Velocity bar element must appear in the JS table template."""
358 await _make_repo(db_session)
359 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
360 response = await client.get(url)
361 assert response.status_code == 200
362 body = response.text
363 assert "velocity-bar" in body
364 assert "velocity-fill" in body
365
366
367 @pytest.mark.anyio
368 async def test_blame_page_beat_range_column(
369 client: AsyncClient,
370 db_session: AsyncSession,
371 ) -> None:
372 """Beat range column must appear in the server-rendered blame table."""
373 await _make_repo(db_session)
374 url = f"/musehub/ui/{_OWNER}/{_SLUG}/blame/{_REF}/{_PATH}"
375 response = await client.get(url)
376 assert response.status_code == 200
377 body = response.text
378 assert "beat-range" in body
379 # Filter form uses HTML name attributes for beat range inputs
380 assert "blame-beat-start" in body
381 assert "blame-beat-end" in body