gabriel / musehub public
test_musehub_ui.py python
8783 lines 292.1 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for MuseHub web UI endpoints.
2
3 Covers (compare view):
4 - test_compare_page_renders — GET /{owner}/{slug}/compare/{base}...{head} returns 200
5 - test_compare_page_no_auth_required — compare page accessible without JWT
6 - test_compare_page_invalid_ref_404 — refs without ... separator return 404
7 - test_compare_page_unknown_owner_404 — unknown owner/slug returns 404
8 - test_compare_page_includes_radar — SSR: all five dimension names present in HTML (replaces JS radar)
9 - test_compare_page_includes_piano_roll — SSR: dimension table header columns in HTML (replaces piano roll JS)
10 - test_compare_page_includes_emotion_diff — SSR: Change delta column present (replaces emotion diff JS)
11 - test_compare_page_includes_commit_list — SSR: all dimension rows present (replaces commit list JS)
12 - test_compare_page_includes_create_pr_button — SSR: both ref names in heading (replaces PR button CTA)
13 - test_compare_json_response — SSR: response is text/html with dimension data (no JSON negotiation)
14 - test_compare_unknown_ref_404 — unknown ref returns 404
15
16
17 Covers acceptance criteria (commit list page):
18 - test_commits_list_page_returns_200 — GET /{owner}/{repo}/commits returns HTML
19 - test_commits_list_page_shows_commit_sha — SHA of seeded commit appears in page
20 - test_commits_list_page_shows_commit_message — message appears in page
21 - test_commits_list_page_dag_indicator — DAG node element present
22 - test_commits_list_page_pagination_links — Older/Newer nav links present when multi-page
23 - test_commits_list_page_branch_selector — branch <select> present when branches exist
24 - test_commits_list_page_json_content_negotiation — ?format=json returns CommitListResponse
25 - test_commits_list_page_json_pagination — ?format=json&per_page=1&page=2 returns page 2
26 - test_commits_list_page_branch_filter_html — ?branch=main filters to that branch
27 - test_commits_list_page_empty_state — repo with no commits shows empty state
28 - test_commits_list_page_merge_indicator — merge commit shows merge indicator
29 - test_commits_list_page_graph_link — link to DAG graph page present
30
31 Covers the minimum acceptance criteria and :
32 - test_ui_repo_page_returns_200 — GET /{repo_id} returns HTML
33 - test_ui_commit_page_shows_artifact_links — commit page HTML mentions img/download
34 - test_ui_pr_list_page_returns_200 — PR list page renders without error
35 - test_ui_issue_list_page_returns_200 — Issue list page renders without error
36 - test_ui_issue_list_has_open_closed_tabs — Open/Closed tab buttons present
37 - test_ui_issue_list_has_sort_controls — Sort buttons (newest/oldest/most-commented) present
38 - test_ui_issue_list_has_label_filter_js — Client-side label filter JS present
39 - test_ui_issue_list_has_body_preview_js — Body preview helper and CSS class present
40 - test_ui_issue_detail_has_comment_section — Comment thread section below issue body
41 - test_ui_issue_detail_has_render_comments_js — buildCommentThread() renders the comment thread
42 - test_ui_issue_detail_has_submit_comment_js — submitComment() posts new comments
43 - test_ui_issue_detail_has_delete_comment_js — deleteComment() removes own comments
44 - test_ui_issue_detail_has_reply_support_js — startReply() enables threaded replies
45 - test_ui_issue_detail_comment_section_below_body — comment section follows issue body card
46 - test_ui_pr_list_has_comment_badge_js — PR list has comment count badge JS
47 - test_ui_pr_list_has_reaction_pills_js — PR list has reaction pills JS
48 - test_ui_issue_list_has_reaction_pills_js — Issue list has reaction pills JS
49 - test_ui_issue_list_eager_social_signals — Issue list eagerly pre-fetches social signals
50 - test_context_page_renders — context viewer page returns 200 HTML
51 - test_context_json_response — JSON returns MuseHubContextResponse structure
52 - test_context_includes_musical_state — response includes active_tracks field
53 - test_context_unknown_ref_404 — nonexistent ref returns 404
54
55 Covers acceptance criteria (tree browser):
56 - test_tree_root_lists_directories
57 - test_tree_subdirectory_lists_files
58 - test_tree_file_icons_by_type
59 - test_tree_breadcrumbs_correct
60 - test_tree_json_response
61 - test_tree_unknown_ref_404
62
63 Covers acceptance criteria (embed player):
64 - test_embed_page_renders — GET /{repo_id}/embed/{ref} returns 200
65 - test_embed_no_auth_required — Public embed accessible without JWT
66 - test_embed_page_x_frame_options — Response sets X-Frame-Options: ALLOWALL
67 - test_embed_page_contains_player_ui — Player elements present in embed HTML
68
69 Covers (emotion map page), migrated to owner/slug routing:
70 - test_emotion_page_renders — GET /{owner}/{repo_slug}/analysis/{ref}/emotion returns 200
71 - test_emotion_page_no_auth_required — emotion UI page accessible without JWT
72 - test_emotion_page_includes_charts — page embeds valence-arousal plot and axis labels
73 - test_emotion_page_includes_filters — page includes primary emotion and confidence display
74 - test_emotion_json_response — JSON endpoint returns emotion map with required fields
75 - test_emotion_trajectory — cross-commit trajectory data is present and ordered
76 - test_emotion_drift_distances — drift list has one entry per consecutive commit pair
77
78 Covers (rich event cards in activity feed):
79 - test_feed_page_returns_200 — GET /feed returns 200 HTML
80 - test_feed_page_no_raw_json_payload — page does not render raw JSON.stringify of payload
81 - test_feed_page_has_event_meta_for_all_types — EVENT_META covers all 8 event types
82 - test_feed_page_has_data_notif_id_attribute — cards carry data-notif-id for mark-as-read hook
83 - test_feed_page_has_unread_indicator — unread highlight border logic present
84 - test_feed_page_has_actor_avatar_logic — actorAvatar / actorHsl helper present
85 - test_feed_page_has_relative_timestamp — fmtRelative called in card rendering
86
87 Covers (mark-as-read UX in activity feed):
88 - test_feed_page_has_mark_one_read_function — markOneRead() defined for per-card action
89 - test_feed_page_has_mark_all_read_function — markAllRead() defined for bulk action
90 - test_feed_page_has_decrement_nav_badge_function — decrementNavBadge() keeps badge in sync
91 - test_feed_page_mark_read_btn_targets_notification_endpoint — calls POST /notifications/{id}/read
92 - test_feed_page_mark_all_btn_targets_read_all_endpoint — calls POST /notifications/read-all
93 - test_feed_page_mark_all_btn_present_in_template — mark-all-read-btn element in page HTML
94 - test_feed_page_mark_read_updates_nav_badge — nav-notif-badge updated after mark-all
95
96 UI routes require no JWT auth (they return HTML shells whose JS handles auth).
97 The HTML content tests assert structural markers present in every rendered page.
98
99 Covers (release detail comment threads):
100 - test_ui_release_detail_has_comment_section — Discussion section present in HTML
101 - test_ui_release_detail_has_render_comments_js — renderComments/submitComment/deleteComment JS present
102 - test_ui_release_detail_comment_uses_release_target_type — target_type='release' used in JS
103 - test_ui_release_detail_has_reply_thread_js — toggleReplyForm/submitReply for thread support
104
105 Covers (commit comment threads):
106 - test_commit_page_has_comment_section_html — comments-section container present in HTML
107 - test_commit_page_has_comment_js_functions — renderComments/submitComment/deleteComment/loadComments JS present
108 - test_commit_page_comment_calls_load_on_startup — loadComments() called at page startup
109 - test_commit_page_comment_uses_correct_api_path — fetches /comments?target_type=commit
110 - test_commit_page_comment_has_avatar_logic — avatarColor() HSL helper present
111 - test_commit_page_comment_has_new_comment_form — new-comment textarea form present
112 - test_commit_page_comment_has_discussion_heading"Discussion" heading present
113
114 Covers regression for PR #282 (owner/slug URL scheme):
115 - test_ui_nav_links_use_owner_slug_not_uuid_* — every page handler injects
116 ``const base = '/{owner}/{slug}'`` not a UUID-based path.
117 - test_ui_unknown_owner_slug_returns_404 — bad owner/slug → 404.
118
119 Covers (analysis dashboard):
120 - test_analysis_dashboard_renders — GET /{owner}/{slug}/analysis/{ref} returns 200
121 - test_analysis_dashboard_no_auth_required — accessible without JWT
122 - test_analysis_dashboard_all_dimension_labels — 10 dimension labels present in HTML
123 - test_analysis_dashboard_sparkline_logic_present — sparkline JS present
124 - test_analysis_dashboard_card_links_to_dimensions — /analysis/ path in page
125 See also test_musehub_analysis.py::test_analysis_aggregate_endpoint_returns_all_dimensions
126
127 Covers (branch list and tag browser):
128 - test_branches_page_lists_all — GET /{owner}/{slug}/branches returns 200 HTML
129 - test_branches_default_marked — default branch badge present in JSON response
130 - test_branches_compare_link — compare link JS present on branches page
131 - test_branches_new_pr_button — new pull request link JS present
132 - test_branches_json_response — JSON returns BranchDetailListResponse with ahead/behind
133 - test_tags_page_lists_all — GET /{owner}/{slug}/tags returns 200 HTML
134 - test_tags_namespace_filter — namespace filter JS present on tags page
135 - test_tags_json_response — JSON returns TagListResponse with namespace grouping
136
137 Covers (audio player — listen page):
138 - test_listen_page_renders — GET /{owner}/{slug}/listen/{ref} returns 200
139 - test_listen_page_no_auth_required — listen page accessible without JWT
140 - test_listen_page_contains_waveform_ui — waveform container and controls present
141 - test_listen_page_contains_play_button — play button element present in HTML
142 - test_listen_page_contains_speed_selector — speed selector element present
143 - test_listen_page_contains_ab_loop_ui — A/B loop controls present
144 - test_listen_page_loads_wavesurfer_vendor — page loads vendored wavesurfer.min.js (no CDN)
145 - test_listen_page_loads_audio_player_js — page loads audio-player.js component script
146 - test_listen_track_page_renders — GET /{owner}/{slug}/listen/{ref}/{path} returns 200
147 - test_listen_track_page_has_track_path_in_js — track path injected into page JS context
148 - test_listen_page_unknown_repo_404 — bad owner/slug → 404
149 - test_listen_page_keyboard_shortcuts_documented — keyboard shortcuts mentioned in page
150 """
151 from __future__ import annotations
152
153 from datetime import UTC, datetime, timedelta, timezone
154
155 import pytest
156 from httpx import AsyncClient
157 from sqlalchemy.ext.asyncio import AsyncSession
158
159 from musehub.db.musehub_models import (
160 MusehubBranch,
161 MusehubCommit,
162 MusehubFollow,
163 MusehubFork,
164 MusehubObject,
165 MusehubProfile,
166 MusehubRelease,
167 MusehubRepo,
168 MusehubSession,
169 MusehubStar,
170 MusehubWatch,
171 )
172
173
174 # ---------------------------------------------------------------------------
175 # Helpers
176 # ---------------------------------------------------------------------------
177
178
179 async def _make_repo(db_session: AsyncSession) -> str:
180 """Seed a minimal repo and return its repo_id."""
181 repo = MusehubRepo(
182 name="test-beats",
183 owner="testuser",
184 slug="test-beats",
185 visibility="private",
186 owner_user_id="test-owner",
187 )
188 db_session.add(repo)
189 await db_session.commit()
190 await db_session.refresh(repo)
191 return str(repo.repo_id)
192
193
194 _TEST_USER_ID = "550e8400-e29b-41d4-a716-446655440000"
195
196
197 async def _make_profile(db_session: AsyncSession, username: str = "testmusician") -> MusehubProfile:
198 """Seed a minimal profile and return it."""
199 profile = MusehubProfile(
200 user_id=_TEST_USER_ID,
201 username=username,
202 bio="Test bio",
203 avatar_url=None,
204 pinned_repo_ids=[],
205 )
206 db_session.add(profile)
207 await db_session.commit()
208 await db_session.refresh(profile)
209 return profile
210
211
212 async def _make_public_repo(db_session: AsyncSession) -> str:
213 """Seed a public repo for the test user and return its repo_id."""
214 repo = MusehubRepo(
215 name="public-beats",
216 owner="testuser",
217 slug="public-beats",
218 visibility="public",
219 owner_user_id=_TEST_USER_ID,
220 )
221 db_session.add(repo)
222 await db_session.commit()
223 await db_session.refresh(repo)
224 return str(repo.repo_id)
225
226
227 # ---------------------------------------------------------------------------
228 # UI route tests (no auth required — routes return HTML)
229 # ---------------------------------------------------------------------------
230
231
232 @pytest.mark.anyio
233 async def test_ui_repo_page_returns_200(
234 client: AsyncClient,
235 db_session: AsyncSession,
236 ) -> None:
237 """GET /{repo_id} returns 200 HTML without requiring a JWT."""
238 repo_id = await _make_repo(db_session)
239 response = await client.get("/testuser/test-beats")
240 assert response.status_code == 200
241 assert "text/html" in response.headers["content-type"]
242 body = response.text
243 # Verify shared chrome is present
244 assert "MuseHub" in body
245 assert "test-beats" in body # repo slug is shown in the page header/title
246 # Verify page-specific content is present (repo home page — file tree + clone section)
247 assert "file-tree" in body or "Empty repository" in body
248
249
250 @pytest.mark.anyio
251 async def test_ui_commit_page_shows_artifact_links(
252 client: AsyncClient,
253 db_session: AsyncSession,
254 ) -> None:
255 """GET /{owner}/{slug}/commits/{commit_id} returns SSR HTML for a known commit.
256
257 Post-SSR migration (issue #583): commit_page() now requires the commit to exist in the
258 DB (returns 404 otherwise) and renders metadata + comments server-side.
259 """
260 from datetime import datetime, timezone
261 from musehub.db.musehub_models import MusehubCommit
262
263 repo_id = await _make_repo(db_session)
264 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
265 commit = MusehubCommit(
266 commit_id=commit_id,
267 repo_id=repo_id,
268 branch="main",
269 parent_ids=[],
270 message="Add bridge section",
271 author="testuser",
272 timestamp=datetime.now(tz=timezone.utc),
273 )
274 db_session.add(commit)
275 await db_session.commit()
276
277 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
278 assert response.status_code == 200
279 assert "text/html" in response.headers["content-type"]
280 body = response.text
281 # SSR: commit message and metadata appear server-side
282 assert "Add bridge section" in body
283 assert "testuser" in body
284 # Links to listen and embed pages present
285 assert f"/listen/{commit_id}" in body
286 assert f"/embed/{commit_id}" in body
287
288
289 @pytest.mark.anyio
290 async def test_ui_pr_list_page_returns_200(
291 client: AsyncClient,
292 db_session: AsyncSession,
293 ) -> None:
294 """GET /{repo_id}/pulls returns 200 HTML without requiring a JWT."""
295 repo_id = await _make_repo(db_session)
296 response = await client.get("/testuser/test-beats/pulls")
297 assert response.status_code == 200
298 assert "text/html" in response.headers["content-type"]
299 body = response.text
300 assert "Pull Requests" in body
301 assert "MuseHub" in body
302 # State filter select element must be present in the JS
303 assert "state" in body
304
305
306 @pytest.mark.anyio
307 async def test_ui_pr_list_has_state_tabs(
308 client: AsyncClient,
309 db_session: AsyncSession,
310 ) -> None:
311 """PR list page includes Open, Merged, Closed, and All HTMX tab links with counts."""
312 await _make_repo(db_session)
313 response = await client.get("/testuser/test-beats/pulls")
314 assert response.status_code == 200
315 body = response.text
316 assert "tab-count" in body
317 assert "tab-btn" in body
318 assert "Open" in body
319 assert "Merged" in body
320 assert "Closed" in body
321 assert "All" in body
322
323
324 @pytest.mark.anyio
325 async def test_ui_pr_list_has_body_preview_js(
326 client: AsyncClient,
327 db_session: AsyncSession,
328 ) -> None:
329 """PR list page SSR renders PR body previews via the pr_rows fragment."""
330 import uuid
331 from musehub.db.musehub_models import MusehubPullRequest
332 repo_id = await _make_repo(db_session)
333 pr_id = uuid.uuid4().hex
334 db_session.add(MusehubPullRequest(
335 pr_id=pr_id, repo_id=repo_id, title="Preview PR",
336 body="This is the body preview text.", state="open",
337 from_branch="feat/preview", to_branch="main", author="testuser",
338 ))
339 await db_session.commit()
340 response = await client.get("/testuser/test-beats/pulls")
341 assert response.status_code == 200
342 body = response.text
343 assert "pr-rows" in body
344 assert "Preview PR" in body
345
346
347 @pytest.mark.anyio
348 async def test_ui_pr_list_has_branch_pills(
349 client: AsyncClient,
350 db_session: AsyncSession,
351 ) -> None:
352 """PR list page SSR renders branch-pill indicators for from/to branches."""
353 import uuid
354 from musehub.db.musehub_models import MusehubPullRequest
355 repo_id = await _make_repo(db_session)
356 pr_id = uuid.uuid4().hex
357 db_session.add(MusehubPullRequest(
358 pr_id=pr_id, repo_id=repo_id, title="Branch pills PR", body="",
359 state="open", from_branch="feat/my-feature", to_branch="main", author="testuser",
360 ))
361 await db_session.commit()
362 response = await client.get("/testuser/test-beats/pulls")
363 assert response.status_code == 200
364 body = response.text
365 assert "branch-pill" in body
366 assert "feat/my-feature" in body
367
368
369 @pytest.mark.anyio
370 async def test_ui_pr_list_has_sort_controls(
371 client: AsyncClient,
372 db_session: AsyncSession,
373 ) -> None:
374 """PR list page HTML includes Newest and Oldest sort buttons."""
375 await _make_repo(db_session)
376 response = await client.get("/testuser/test-beats/pulls")
377 assert response.status_code == 200
378 body = response.text
379 assert "Newest" in body
380 assert "Oldest" in body
381 assert "sort-btn" in body
382
383
384 @pytest.mark.anyio
385 async def test_ui_pr_list_has_merged_badge_markup(
386 client: AsyncClient,
387 db_session: AsyncSession,
388 ) -> None:
389 """PR list page SSR renders a Merged badge with merge commit short-SHA link for merged PRs."""
390 import uuid
391 from musehub.db.musehub_models import MusehubPullRequest
392 repo_id = await _make_repo(db_session)
393 pr_id = uuid.uuid4().hex
394 commit_id = uuid.uuid4().hex
395 db_session.add(MusehubPullRequest(
396 pr_id=pr_id, repo_id=repo_id, title="Merged PR", body="",
397 state="merged", from_branch="feat/merged", to_branch="main",
398 author="testuser", merge_commit_id=commit_id,
399 ))
400 await db_session.commit()
401 response = await client.get("/testuser/test-beats/pulls?state=merged")
402 assert response.status_code == 200
403 body = response.text
404 assert "badge-merged" in body
405 assert commit_id[:8] in body
406
407
408 @pytest.mark.anyio
409 async def test_ui_pr_list_has_closed_badge_markup(
410 client: AsyncClient,
411 db_session: AsyncSession,
412 ) -> None:
413 """PR list page SSR renders a Closed badge for closed PRs."""
414 import uuid
415 from musehub.db.musehub_models import MusehubPullRequest
416 repo_id = await _make_repo(db_session)
417 pr_id = uuid.uuid4().hex
418 db_session.add(MusehubPullRequest(
419 pr_id=pr_id, repo_id=repo_id, title="Closed PR", body="",
420 state="closed", from_branch="feat/closed", to_branch="main", author="testuser",
421 ))
422 await db_session.commit()
423 response = await client.get("/testuser/test-beats/pulls?state=closed")
424 assert response.status_code == 200
425 body = response.text
426 assert "badge-closed" in body
427
428
429 @pytest.mark.anyio
430 async def test_ui_issue_list_page_returns_200(
431 client: AsyncClient,
432 db_session: AsyncSession,
433 ) -> None:
434 """GET /{repo_id}/issues returns 200 HTML without requiring a JWT."""
435 repo_id = await _make_repo(db_session)
436 response = await client.get("/testuser/test-beats/issues")
437 assert response.status_code == 200
438 assert "text/html" in response.headers["content-type"]
439 body = response.text
440 assert "Issues" in body
441 assert "MuseHub" in body
442
443
444 @pytest.mark.anyio
445 async def test_ui_issue_list_has_open_closed_tabs(
446 client: AsyncClient,
447 db_session: AsyncSession,
448 ) -> None:
449 """Issue list page HTML includes Open and Closed tab buttons and count spans."""
450 await _make_repo(db_session)
451 response = await client.get("/testuser/test-beats/issues")
452 assert response.status_code == 200
453 body = response.text
454 # Tab buttons for open and closed state
455 assert "tab-open" in body
456 assert "tab-closed" in body
457 # Tab count placeholders are rendered client-side; structural markers exist
458 assert "tab-count" in body
459 assert "Open" in body
460 assert "Closed" in body
461
462
463 @pytest.mark.anyio
464 async def test_ui_issue_list_has_sort_controls(
465 client: AsyncClient,
466 db_session: AsyncSession,
467 ) -> None:
468 """Issue list page HTML includes Newest, Oldest, and Most commented sort controls.
469
470 The issue list uses SSR radio buttons with server-side sort parameters
471 (converted from client-side changeSort() as part of the HTMX migration).
472 """
473 await _make_repo(db_session)
474 response = await client.get("/testuser/test-beats/issues")
475 assert response.status_code == 200
476 body = response.text
477 assert "Newest" in body
478 assert "Oldest" in body
479 assert "Most commented" in body
480 assert "sort-radio-group" in body
481
482
483 @pytest.mark.anyio
484 async def test_ui_issue_list_has_label_filter_js(
485 client: AsyncClient,
486 db_session: AsyncSession,
487 ) -> None:
488 """Issue list page HTML includes SSR label filter chips."""
489 await _make_repo(db_session)
490 response = await client.get("/testuser/test-beats/issues")
491 assert response.status_code == 200
492 body = response.text
493 assert "label-chip-container" in body
494 assert "filter-section" in body
495
496
497 @pytest.mark.anyio
498 async def test_ui_issue_list_has_body_preview_js(
499 client: AsyncClient,
500 db_session: AsyncSession,
501 ) -> None:
502 """Issue list page HTML dispatches the issue-list TypeScript module and renders structure."""
503 await _make_repo(db_session)
504 response = await client.get("/testuser/test-beats/issues")
505 assert response.status_code == 200
506 body = response.text
507 # bodyPreview is now in app.js (TypeScript module); check the page dispatch JSON and structure
508 assert '"page": "issue-list"' in body
509 assert "issues-layout" in body
510
511
512 @pytest.mark.anyio
513 async def test_ui_pr_list_has_comment_badge_js(
514 client: AsyncClient,
515 db_session: AsyncSession,
516 ) -> None:
517 """PR list page renders the SSR tab counts and HTMX state filters."""
518 await _make_repo(db_session)
519 response = await client.get("/testuser/test-beats/pulls")
520 assert response.status_code == 200
521 body = response.text
522 assert "tab-count" in body
523 assert "pr-rows" in body
524 assert "hx-get" in body
525
526
527 @pytest.mark.anyio
528 async def test_ui_pr_list_has_reaction_pills_js(
529 client: AsyncClient,
530 db_session: AsyncSession,
531 ) -> None:
532 """PR list page renders the SSR open/merged/closed state tabs."""
533 await _make_repo(db_session)
534 response = await client.get("/testuser/test-beats/pulls")
535 assert response.status_code == 200
536 body = response.text
537 assert "tab-btn" in body
538 assert "state=open" in body
539 assert "state=merged" in body
540
541
542 @pytest.mark.anyio
543 async def test_ui_issue_list_has_reaction_pills_js(
544 client: AsyncClient,
545 db_session: AsyncSession,
546 ) -> None:
547 """Issue list page renders the SSR filter sidebar."""
548 await _make_repo(db_session)
549 response = await client.get("/testuser/test-beats/issues")
550 assert response.status_code == 200
551 body = response.text
552 assert "filter-sidebar" in body or "filter-select" in body
553 assert "hx-get" in body
554
555
556 @pytest.mark.anyio
557 async def test_ui_issue_list_eager_social_signals(
558 client: AsyncClient,
559 db_session: AsyncSession,
560 ) -> None:
561 """Issue list page renders the SSR issue rows container."""
562 await _make_repo(db_session)
563 response = await client.get("/testuser/test-beats/issues")
564 assert response.status_code == 200
565 body = response.text
566 assert "issue-rows" in body or "Issues" in body
567 assert "hx-get" in body
568
569
570 @pytest.mark.anyio
571 async def test_ui_pr_detail_page_returns_200(
572 client: AsyncClient,
573 db_session: AsyncSession,
574 ) -> None:
575 """GET /{owner}/{slug}/pulls/{pr_id} returns 200 HTML."""
576 import uuid
577 from musehub.db.musehub_models import MusehubPullRequest
578 repo_id = await _make_repo(db_session)
579 pr_id = uuid.uuid4().hex
580 db_session.add(MusehubPullRequest(
581 pr_id=pr_id, repo_id=repo_id, title="Add blues riff", body="",
582 state="open", from_branch="feat/blues", to_branch="main", author="testuser",
583 ))
584 await db_session.commit()
585 response = await client.get(f"/testuser/test-beats/pulls/{pr_id}")
586 assert response.status_code == 200
587 assert "text/html" in response.headers["content-type"]
588 body = response.text
589 assert "MuseHub" in body
590 assert "Merge pull request" in body
591
592
593 @pytest.mark.anyio
594 async def test_ui_pr_detail_page_has_comment_section(
595 client: AsyncClient,
596 db_session: AsyncSession,
597 ) -> None:
598 """PR detail page includes the SSR comment thread section."""
599 import uuid
600 from musehub.db.musehub_models import MusehubPullRequest
601 repo_id = await _make_repo(db_session)
602 pr_id = uuid.uuid4().hex
603 db_session.add(MusehubPullRequest(
604 pr_id=pr_id, repo_id=repo_id, title="Comment test PR", body="",
605 state="open", from_branch="feat/comments", to_branch="main", author="testuser",
606 ))
607 await db_session.commit()
608 response = await client.get(f"/testuser/test-beats/pulls/{pr_id}")
609 assert response.status_code == 200
610 body = response.text
611 assert "pr-comments" in body
612 assert "comment-block" in body or "Leave a review comment" in body
613
614
615 @pytest.mark.anyio
616 async def test_ui_pr_detail_page_has_reaction_bar(
617 client: AsyncClient,
618 db_session: AsyncSession,
619 ) -> None:
620 """PR detail page includes the HTMX merge controls and comment form."""
621 import uuid
622 from musehub.db.musehub_models import MusehubPullRequest
623 repo_id = await _make_repo(db_session)
624 pr_id = uuid.uuid4().hex
625 db_session.add(MusehubPullRequest(
626 pr_id=pr_id, repo_id=repo_id, title="Reaction test PR", body="",
627 state="open", from_branch="feat/react", to_branch="main", author="testuser",
628 ))
629 await db_session.commit()
630 response = await client.get(f"/testuser/test-beats/pulls/{pr_id}")
631 assert response.status_code == 200
632 body = response.text
633 assert "pr-detail-layout" in body
634 assert "merge-section" in body
635
636
637 @pytest.mark.anyio
638 async def test_pr_detail_shows_diff_radar(
639 client: AsyncClient,
640 db_session: AsyncSession,
641 ) -> None:
642 """PR detail page HTML contains the musical diff section."""
643 import uuid
644 from musehub.db.musehub_models import MusehubPullRequest
645 repo_id = await _make_repo(db_session)
646 pr_id = uuid.uuid4().hex
647 db_session.add(MusehubPullRequest(
648 pr_id=pr_id, repo_id=repo_id, title="Diff radar PR", body="",
649 state="open", from_branch="feat/diff", to_branch="main", author="testuser",
650 ))
651 await db_session.commit()
652 response = await client.get(f"/testuser/test-beats/pulls/{pr_id}")
653 assert response.status_code == 200
654 body = response.text
655 # diff-stat CSS class is in app.css (SCSS); verify structural layout class instead
656 assert "pr-detail-layout" in body
657 assert "branch-pill" in body
658
659
660 @pytest.mark.anyio
661 async def test_pr_detail_audio_ab(
662 client: AsyncClient,
663 db_session: AsyncSession,
664 ) -> None:
665 """PR detail page HTML contains the branch pills (from/to branch info)."""
666 import uuid
667 from musehub.db.musehub_models import MusehubPullRequest
668 repo_id = await _make_repo(db_session)
669 pr_id = uuid.uuid4().hex
670 db_session.add(MusehubPullRequest(
671 pr_id=pr_id, repo_id=repo_id, title="Audio AB PR", body="",
672 state="open", from_branch="feat/audio", to_branch="main", author="testuser",
673 ))
674 await db_session.commit()
675 response = await client.get(f"/testuser/test-beats/pulls/{pr_id}")
676 assert response.status_code == 200
677 body = response.text
678 assert "branch-pill" in body
679 assert "feat/audio" in body
680 assert "main" in body
681
682
683 @pytest.mark.anyio
684 async def test_pr_detail_merge_strategies(
685 client: AsyncClient,
686 db_session: AsyncSession,
687 ) -> None:
688 """PR detail page HTML contains the HTMX merge strategy buttons."""
689 import uuid
690 from musehub.db.musehub_models import MusehubPullRequest
691 repo_id = await _make_repo(db_session)
692 pr_id = uuid.uuid4().hex
693 db_session.add(MusehubPullRequest(
694 pr_id=pr_id, repo_id=repo_id, title="Merge strategy PR", body="",
695 state="open", from_branch="feat/merge", to_branch="main", author="testuser",
696 ))
697 await db_session.commit()
698 response = await client.get(f"/testuser/test-beats/pulls/{pr_id}")
699 assert response.status_code == 200
700 body = response.text
701 assert "merge_commit" in body
702 assert "squash" in body
703 assert "rebase" in body
704 assert "hx-post" in body
705
706
707 @pytest.mark.anyio
708 async def test_pr_detail_json_response(
709 client: AsyncClient,
710 db_session: AsyncSession,
711 auth_headers: dict[str, str],
712 ) -> None:
713 """PR detail page ?format=json returns structured diff data for agent consumption."""
714 from datetime import datetime, timezone
715
716 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubPullRequest
717
718 repo_id = await _make_repo(db_session)
719 commit_id = "aabbccddeeff00112233445566778899aabbccdd"
720 commit = MusehubCommit(
721 commit_id=commit_id,
722 repo_id=repo_id,
723 branch="feat/blues-riff",
724 parent_ids=[],
725 message="Add harmonic chord progression in Dm",
726 author="musician",
727 timestamp=datetime.now(tz=timezone.utc),
728 )
729 branch = MusehubBranch(
730 repo_id=repo_id,
731 name="feat/blues-riff",
732 head_commit_id=commit_id,
733 )
734 db_session.add(commit)
735 db_session.add(branch)
736 import uuid
737 pr_id = uuid.uuid4().hex
738 pr = MusehubPullRequest(
739 pr_id=pr_id,
740 repo_id=repo_id,
741 title="Add blues riff",
742 body="",
743 state="open",
744 from_branch="feat/blues-riff",
745 to_branch="main",
746 author="musician",
747 )
748 db_session.add(pr)
749 await db_session.commit()
750
751 response = await client.get(
752 f"/testuser/test-beats/pulls/{pr_id}?format=json",
753 headers=auth_headers,
754 )
755 assert response.status_code == 200
756 data = response.json()
757 # Must include dimension scores and overall score
758 assert "dimensions" in data
759 assert "overallScore" in data
760 assert isinstance(data["dimensions"], list)
761 assert len(data["dimensions"]) == 5
762 assert data["prId"] == pr_id
763 assert data["fromBranch"] == "feat/blues-riff"
764 assert data["toBranch"] == "main"
765 # Each dimension must have the expected fields
766 dim = data["dimensions"][0]
767 assert "dimension" in dim
768 assert "score" in dim
769 assert "level" in dim
770 assert "deltaLabel" in dim
771
772
773 # ---------------------------------------------------------------------------
774 # Tests for musehub_divergence service helpers
775 # ---------------------------------------------------------------------------
776
777
778 def test_extract_affected_sections_empty_when_no_section_keywords() -> None:
779 """affected_sections returns [] when no commit message mentions a section keyword."""
780 from musehub.services.musehub_divergence import extract_affected_sections
781
782 messages = (
783 "Add jazzy chord progression in Dm",
784 "Rework drum pattern for more swing",
785 "Fix melody pitch drift on lead synth",
786 )
787 assert extract_affected_sections(messages) == []
788
789
790 def test_extract_affected_sections_finds_mentioned_keywords() -> None:
791 """affected_sections returns only section keywords actually present in commit messages."""
792 from musehub.services.musehub_divergence import extract_affected_sections
793
794 messages = (
795 "Rewrite chorus melody to be more catchy",
796 "Add tension to the bridge section",
797 "Clean up drum loop in verse 2",
798 )
799 result = extract_affected_sections(messages)
800 assert "Chorus" in result
801 assert "Bridge" in result
802 assert "Verse" in result
803 # Keywords NOT mentioned should not appear
804 assert "Intro" not in result
805 assert "Outro" not in result
806
807
808 def test_extract_affected_sections_case_insensitive() -> None:
809 """Section keyword matching is case-insensitive."""
810 from musehub.services.musehub_divergence import extract_affected_sections
811
812 messages = ("CHORUS rework", "New INTRO material", "bridge transition")
813 result = extract_affected_sections(messages)
814 assert "Chorus" in result
815 assert "Intro" in result
816 assert "Bridge" in result
817
818
819 def test_extract_affected_sections_deduplicates() -> None:
820 """Each keyword appears at most once even when mentioned in multiple commits."""
821 from musehub.services.musehub_divergence import extract_affected_sections
822
823 messages = ("fix chorus", "rewrite chorus progression", "shorten chorus tail")
824 result = extract_affected_sections(messages)
825 assert result.count("Chorus") == 1
826
827
828 def test_build_pr_diff_response_affected_sections_from_commits() -> None:
829 """build_pr_diff_response populates affected_sections from commit messages, not score."""
830 from musehub.services.musehub_divergence import (
831 MuseHubDimensionDivergence,
832 MuseHubDivergenceLevel,
833 MuseHubDivergenceResult,
834 build_pr_diff_response,
835 )
836
837 structural_dim = MuseHubDimensionDivergence(
838 dimension="structural",
839 level=MuseHubDivergenceLevel.HIGH,
840 score=0.9,
841 description="High structural divergence",
842 branch_a_commits=3,
843 branch_b_commits=0,
844 )
845 other_dims = tuple(
846 MuseHubDimensionDivergence(
847 dimension=dim,
848 level=MuseHubDivergenceLevel.NONE,
849 score=0.0,
850 description=f"No {dim} changes.",
851 branch_a_commits=0,
852 branch_b_commits=0,
853 )
854 for dim in ("melodic", "harmonic", "rhythmic", "dynamic")
855 )
856 result = MuseHubDivergenceResult(
857 repo_id="repo-1",
858 branch_a="main",
859 branch_b="feat/new-structure",
860 common_ancestor="abc123",
861 dimensions=(structural_dim,) + other_dims,
862 overall_score=0.18,
863 # No section keywords in any commit message → affected_sections should be []
864 all_messages=("Add chord progression", "Refine drum groove"),
865 )
866
867 response = build_pr_diff_response(
868 pr_id="pr-abc",
869 from_branch="feat/new-structure",
870 to_branch="main",
871 result=result,
872 )
873
874 assert response.affected_sections == []
875 assert response.overall_score == 0.18
876 assert len(response.dimensions) == 5
877
878
879 def test_build_pr_diff_response_affected_sections_present_when_mentioned() -> None:
880 """build_pr_diff_response returns affected_sections when commits mention section keywords."""
881 from musehub.services.musehub_divergence import (
882 MuseHubDimensionDivergence,
883 MuseHubDivergenceLevel,
884 MuseHubDivergenceResult,
885 build_pr_diff_response,
886 )
887
888 dims = tuple(
889 MuseHubDimensionDivergence(
890 dimension=dim,
891 level=MuseHubDivergenceLevel.NONE,
892 score=0.0,
893 description=f"No {dim} changes.",
894 branch_a_commits=0,
895 branch_b_commits=0,
896 )
897 for dim in ("melodic", "harmonic", "rhythmic", "structural", "dynamic")
898 )
899 result = MuseHubDivergenceResult(
900 repo_id="repo-2",
901 branch_a="main",
902 branch_b="feat/chorus-rework",
903 common_ancestor="def456",
904 dimensions=dims,
905 overall_score=0.0,
906 all_messages=("Rework the chorus hook", "Add bridge leading into outro"),
907 )
908
909 response = build_pr_diff_response(
910 pr_id="pr-def",
911 from_branch="feat/chorus-rework",
912 to_branch="main",
913 result=result,
914 )
915
916 assert "Chorus" in response.affected_sections
917 assert "Bridge" in response.affected_sections
918 assert "Outro" in response.affected_sections
919 assert "Verse" not in response.affected_sections
920
921
922 def test_build_zero_diff_response_returns_empty_affected_sections() -> None:
923 """build_zero_diff_response always returns [] for affected_sections."""
924 from musehub.services.musehub_divergence import build_zero_diff_response
925
926 response = build_zero_diff_response(
927 pr_id="pr-zero",
928 repo_id="repo-zero",
929 from_branch="feat/empty",
930 to_branch="main",
931 )
932
933 assert response.affected_sections == []
934 assert response.overall_score == 0.0
935 assert all(d.score == 0.0 for d in response.dimensions)
936 assert len(response.dimensions) == 5
937
938
939 @pytest.mark.anyio
940 async def test_diff_api_affected_sections_empty_without_section_keywords(
941 client: AsyncClient,
942 db_session: AsyncSession,
943 auth_headers: dict[str, str],
944 ) -> None:
945 """GET /diff returns affected_sections=[] when commit messages mention no section keywords."""
946 import uuid
947 from datetime import datetime, timezone
948
949 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubPullRequest
950
951 repo_id = await _make_repo(db_session)
952 commit_id = uuid.uuid4().hex
953 commit = MusehubCommit(
954 commit_id=commit_id,
955 repo_id=repo_id,
956 branch="feat/harmonic-twist",
957 parent_ids=[],
958 message="Add jazzy chord progression in Dm",
959 author="musician",
960 timestamp=datetime.now(tz=timezone.utc),
961 )
962 branch = MusehubBranch(
963 repo_id=repo_id,
964 name="feat/harmonic-twist",
965 head_commit_id=commit_id,
966 )
967 pr_id = uuid.uuid4().hex
968 pr = MusehubPullRequest(
969 pr_id=pr_id,
970 repo_id=repo_id,
971 title="Harmonic twist",
972 body="",
973 state="open",
974 from_branch="feat/harmonic-twist",
975 to_branch="main",
976 author="musician",
977 )
978 db_session.add_all([commit, branch, pr])
979 await db_session.commit()
980
981 response = await client.get(
982 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/diff",
983 headers=auth_headers,
984 )
985 assert response.status_code == 200
986 data = response.json()
987 assert data["affectedSections"] == []
988
989
990 @pytest.mark.anyio
991 async def test_diff_api_affected_sections_populated_from_commit_message(
992 client: AsyncClient,
993 db_session: AsyncSession,
994 auth_headers: dict[str, str],
995 ) -> None:
996 """GET /diff returns affected_sections populated from commit messages mentioning sections."""
997 import uuid
998 from datetime import datetime, timezone
999
1000 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubPullRequest
1001
1002 repo_id = await _make_repo(db_session)
1003 # main branch needs at least one commit for compute_hub_divergence to succeed
1004 main_commit_id = uuid.uuid4().hex
1005 main_commit = MusehubCommit(
1006 commit_id=main_commit_id,
1007 repo_id=repo_id,
1008 branch="main",
1009 parent_ids=[],
1010 message="Initial composition",
1011 author="musician",
1012 timestamp=datetime.now(tz=timezone.utc),
1013 )
1014 main_branch = MusehubBranch(
1015 repo_id=repo_id,
1016 name="main",
1017 head_commit_id=main_commit_id,
1018 )
1019 commit_id = uuid.uuid4().hex
1020 commit = MusehubCommit(
1021 commit_id=commit_id,
1022 repo_id=repo_id,
1023 branch="feat/chorus-rework",
1024 parent_ids=[main_commit_id],
1025 message="Rewrite the chorus to be more energetic and add new bridge",
1026 author="musician",
1027 timestamp=datetime.now(tz=timezone.utc),
1028 )
1029 branch = MusehubBranch(
1030 repo_id=repo_id,
1031 name="feat/chorus-rework",
1032 head_commit_id=commit_id,
1033 )
1034 pr_id = uuid.uuid4().hex
1035 pr = MusehubPullRequest(
1036 pr_id=pr_id,
1037 repo_id=repo_id,
1038 title="Chorus rework",
1039 body="",
1040 state="open",
1041 from_branch="feat/chorus-rework",
1042 to_branch="main",
1043 author="musician",
1044 )
1045 db_session.add_all([main_commit, main_branch, commit, branch, pr])
1046 await db_session.commit()
1047
1048 response = await client.get(
1049 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/diff",
1050 headers=auth_headers,
1051 )
1052 assert response.status_code == 200
1053 data = response.json()
1054 sections = data["affectedSections"]
1055 assert "Chorus" in sections
1056 assert "Bridge" in sections
1057 assert "Verse" not in sections
1058
1059
1060 @pytest.mark.anyio
1061 async def test_ui_diff_json_affected_sections_from_commit_message(
1062 client: AsyncClient,
1063 db_session: AsyncSession,
1064 auth_headers: dict[str, str],
1065 ) -> None:
1066 """PR detail ?format=json returns affected_sections derived from commit messages."""
1067 import uuid
1068 from datetime import datetime, timezone
1069
1070 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubPullRequest
1071
1072 repo_id = await _make_repo(db_session)
1073 # main branch needs at least one commit for compute_hub_divergence to succeed
1074 main_commit_id = uuid.uuid4().hex
1075 main_commit = MusehubCommit(
1076 commit_id=main_commit_id,
1077 repo_id=repo_id,
1078 branch="main",
1079 parent_ids=[],
1080 message="Initial composition",
1081 author="musician",
1082 timestamp=datetime.now(tz=timezone.utc),
1083 )
1084 main_branch = MusehubBranch(
1085 repo_id=repo_id,
1086 name="main",
1087 head_commit_id=main_commit_id,
1088 )
1089 commit_id = uuid.uuid4().hex
1090 commit = MusehubCommit(
1091 commit_id=commit_id,
1092 repo_id=repo_id,
1093 branch="feat/verse-update",
1094 parent_ids=[main_commit_id],
1095 message="Extend verse 2 with new melodic motif",
1096 author="musician",
1097 timestamp=datetime.now(tz=timezone.utc),
1098 )
1099 branch = MusehubBranch(
1100 repo_id=repo_id,
1101 name="feat/verse-update",
1102 head_commit_id=commit_id,
1103 )
1104 pr_id = uuid.uuid4().hex
1105 pr = MusehubPullRequest(
1106 pr_id=pr_id,
1107 repo_id=repo_id,
1108 title="Verse update",
1109 body="",
1110 state="open",
1111 from_branch="feat/verse-update",
1112 to_branch="main",
1113 author="musician",
1114 )
1115 db_session.add_all([main_commit, main_branch, commit, branch, pr])
1116 await db_session.commit()
1117
1118 response = await client.get(
1119 f"/testuser/test-beats/pulls/{pr_id}?format=json",
1120 headers=auth_headers,
1121 )
1122 assert response.status_code == 200
1123 data = response.json()
1124 assert "Verse" in data["affectedSections"]
1125 assert "Chorus" not in data["affectedSections"]
1126
1127
1128 @pytest.mark.anyio
1129 async def test_ui_issue_detail_page_returns_200(
1130 client: AsyncClient,
1131 db_session: AsyncSession,
1132 ) -> None:
1133 """GET /{owner}/{slug}/issues/{number} returns 200 HTML."""
1134 from musehub.db.musehub_models import MusehubIssue
1135 repo_id = await _make_repo(db_session)
1136 db_session.add(MusehubIssue(
1137 repo_id=repo_id, number=1, title="Test issue", body="",
1138 state="open", labels=[], author="testuser",
1139 ))
1140 await db_session.commit()
1141 response = await client.get("/testuser/test-beats/issues/1")
1142 assert response.status_code == 200
1143 assert "text/html" in response.headers["content-type"]
1144 body = response.text
1145 assert "MuseHub" in body
1146 assert "Close issue" in body
1147
1148
1149 @pytest.mark.anyio
1150 async def test_ui_issue_detail_has_comment_section(
1151 client: AsyncClient,
1152 db_session: AsyncSession,
1153 ) -> None:
1154 """Issue detail page includes the SSR comment thread section."""
1155 from musehub.db.musehub_models import MusehubIssue
1156 repo_id = await _make_repo(db_session)
1157 db_session.add(MusehubIssue(
1158 repo_id=repo_id, number=1, title="Test issue", body="",
1159 state="open", labels=[], author="testuser",
1160 ))
1161 await db_session.commit()
1162 response = await client.get("/testuser/test-beats/issues/1")
1163 assert response.status_code == 200
1164 body = response.text
1165 assert "Discussion" in body
1166 assert "issue-comments" in body
1167
1168
1169 @pytest.mark.anyio
1170 async def test_ui_issue_detail_has_render_comments_js(
1171 client: AsyncClient,
1172 db_session: AsyncSession,
1173 ) -> None:
1174 """Issue detail page includes HTMX comment thread with /comments endpoint."""
1175 from musehub.db.musehub_models import MusehubIssue
1176 repo_id = await _make_repo(db_session)
1177 db_session.add(MusehubIssue(
1178 repo_id=repo_id, number=1, title="Test issue", body="",
1179 state="open", labels=[], author="testuser",
1180 ))
1181 await db_session.commit()
1182 response = await client.get("/testuser/test-beats/issues/1")
1183 assert response.status_code == 200
1184 body = response.text
1185 assert "/comments" in body
1186 assert "hx-post" in body
1187
1188
1189 @pytest.mark.anyio
1190 async def test_ui_issue_detail_has_submit_comment_js(
1191 client: AsyncClient,
1192 db_session: AsyncSession,
1193 ) -> None:
1194 """Issue detail page includes the HTMX new-comment form."""
1195 from musehub.db.musehub_models import MusehubIssue
1196 repo_id = await _make_repo(db_session)
1197 db_session.add(MusehubIssue(
1198 repo_id=repo_id, number=1, title="Test issue", body="",
1199 state="open", labels=[], author="testuser",
1200 ))
1201 await db_session.commit()
1202 response = await client.get("/testuser/test-beats/issues/1")
1203 assert response.status_code == 200
1204 body = response.text
1205 assert "Leave a comment" in body
1206 assert "Comment" in body
1207
1208
1209 @pytest.mark.anyio
1210 async def test_ui_issue_detail_has_delete_comment_js(
1211 client: AsyncClient,
1212 db_session: AsyncSession,
1213 ) -> None:
1214 """Issue detail page renders the issue body and comment count."""
1215 from musehub.db.musehub_models import MusehubIssue
1216 repo_id = await _make_repo(db_session)
1217 db_session.add(MusehubIssue(
1218 repo_id=repo_id, number=1, title="Test issue", body="",
1219 state="open", labels=[], author="testuser",
1220 ))
1221 await db_session.commit()
1222 response = await client.get("/testuser/test-beats/issues/1")
1223 assert response.status_code == 200
1224 body = response.text
1225 assert "issue-body" in body
1226 assert "comment" in body
1227
1228
1229 @pytest.mark.anyio
1230 async def test_ui_issue_detail_has_reply_support_js(
1231 client: AsyncClient,
1232 db_session: AsyncSession,
1233 ) -> None:
1234 """Issue detail page renders the issue detail grid layout."""
1235 from musehub.db.musehub_models import MusehubIssue
1236 repo_id = await _make_repo(db_session)
1237 db_session.add(MusehubIssue(
1238 repo_id=repo_id, number=1, title="Test issue", body="",
1239 state="open", labels=[], author="testuser",
1240 ))
1241 await db_session.commit()
1242 response = await client.get("/testuser/test-beats/issues/1")
1243 assert response.status_code == 200
1244 body = response.text
1245 assert "issue-detail-grid" in body
1246 # comment-replies only renders when replies exist; check comment form structure instead
1247 assert "comment-thread" in body or "new-comment" in body or "issue-detail-grid" in body
1248
1249
1250 @pytest.mark.anyio
1251 async def test_ui_issue_detail_comment_section_below_body(
1252 client: AsyncClient,
1253 db_session: AsyncSession,
1254 ) -> None:
1255 """Comment section appears after the issue body card in document order."""
1256 from musehub.db.musehub_models import MusehubIssue
1257 repo_id = await _make_repo(db_session)
1258 db_session.add(MusehubIssue(
1259 repo_id=repo_id, number=1, title="Test issue", body="",
1260 state="open", labels=[], author="testuser",
1261 ))
1262 await db_session.commit()
1263 response = await client.get("/testuser/test-beats/issues/1")
1264 assert response.status_code == 200
1265 body = response.text
1266 body_pos = body.find("issue-body")
1267 comments_pos = body.find("issue-comments")
1268 assert body_pos != -1, "issue-body not found"
1269 assert comments_pos != -1, "issue-comments not found"
1270 assert comments_pos > body_pos, "comment section must appear after the issue body"
1271
1272
1273 @pytest.mark.anyio
1274 async def test_ui_repo_page_no_auth_required(
1275 client: AsyncClient,
1276 db_session: AsyncSession,
1277 ) -> None:
1278 """UI routes must be accessible without an Authorization header."""
1279 repo_id = await _make_repo(db_session)
1280 response = await client.get("/testuser/test-beats")
1281 # Must NOT return 401 — HTML shell has no auth requirement
1282 assert response.status_code != 401
1283 assert response.status_code == 200
1284
1285
1286 @pytest.mark.anyio
1287 async def test_ui_pages_include_token_form(
1288 client: AsyncClient,
1289 db_session: AsyncSession,
1290 ) -> None:
1291 """Every UI page embeds the JWT token input form and app.js via base.html."""
1292 repo_id = await _make_repo(db_session)
1293 for path in [
1294 "/testuser/test-beats",
1295 "/testuser/test-beats/pulls",
1296 "/testuser/test-beats/issues",
1297 "/testuser/test-beats/releases",
1298 ]:
1299 response = await client.get(path)
1300 assert response.status_code == 200
1301 body = response.text
1302 assert "static/app.js" in body
1303 assert "token-form" in body
1304
1305
1306 @pytest.mark.anyio
1307 async def test_ui_release_list_page_returns_200(
1308 client: AsyncClient,
1309 db_session: AsyncSession,
1310 ) -> None:
1311 """GET /{owner}/{slug}/releases returns 200 HTML without requiring a JWT."""
1312 repo_id = await _make_repo(db_session)
1313 response = await client.get("/testuser/test-beats/releases")
1314 assert response.status_code == 200
1315 assert "text/html" in response.headers["content-type"]
1316 body = response.text
1317 assert "Releases" in body
1318 assert "MuseHub" in body
1319 assert "testuser" in body
1320
1321
1322 @pytest.mark.anyio
1323 async def test_ui_release_list_page_has_download_buttons(
1324 client: AsyncClient,
1325 db_session: AsyncSession,
1326 ) -> None:
1327 """Release list page renders SSR download buttons for all package types."""
1328 repo_id = await _make_repo(db_session)
1329 release = MusehubRelease(
1330 repo_id=repo_id, tag="v1.0", title="Version 1.0",
1331 body="", author="testuser", download_urls={},
1332 )
1333 db_session.add(release)
1334 await db_session.commit()
1335 response = await client.get("/testuser/test-beats/releases")
1336 assert response.status_code == 200
1337 body = response.text
1338 assert "MIDI" in body
1339 assert "Stems" in body
1340 assert "MP3" in body
1341 assert "MusicXML" in body
1342 assert "dl-btn" in body
1343
1344
1345 @pytest.mark.anyio
1346 async def test_ui_release_list_page_has_body_preview(
1347 client: AsyncClient,
1348 db_session: AsyncSession,
1349 ) -> None:
1350 """Release list page renders SSR body preview for releases that have notes."""
1351 repo_id = await _make_repo(db_session)
1352 release = MusehubRelease(
1353 repo_id=repo_id, tag="v1.0", title="Version 1.0",
1354 body="This is the release body preview text.", author="testuser",
1355 )
1356 db_session.add(release)
1357 await db_session.commit()
1358 response = await client.get("/testuser/test-beats/releases")
1359 assert response.status_code == 200
1360 body = response.text
1361 assert "release-body-preview" in body
1362 assert "This is the release body preview text." in body
1363
1364
1365 @pytest.mark.anyio
1366 async def test_ui_release_list_page_has_download_count_badge(
1367 client: AsyncClient,
1368 db_session: AsyncSession,
1369 ) -> None:
1370 """Release list page renders SSR download link buttons for each release."""
1371 repo_id = await _make_repo(db_session)
1372 release = MusehubRelease(
1373 repo_id=repo_id, tag="v1.0", title="Version 1.0",
1374 body="", author="testuser", download_urls={},
1375 )
1376 db_session.add(release)
1377 await db_session.commit()
1378 response = await client.get("/testuser/test-beats/releases")
1379 assert response.status_code == 200
1380 body = response.text
1381 assert "release-downloads" in body
1382 assert "dl-btn" in body
1383
1384
1385 @pytest.mark.anyio
1386 async def test_ui_release_list_page_has_commit_link(
1387 client: AsyncClient,
1388 db_session: AsyncSession,
1389 ) -> None:
1390 """Release list page links a release's commit_id to the commit detail page."""
1391 repo_id = await _make_repo(db_session)
1392 release = MusehubRelease(
1393 repo_id=repo_id, tag="v1.0", title="Version 1.0",
1394 body="", author="testuser", commit_id="abc1234567890",
1395 )
1396 db_session.add(release)
1397 await db_session.commit()
1398 response = await client.get("/testuser/test-beats/releases")
1399 assert response.status_code == 200
1400 body = response.text
1401 assert "/commits/" in body
1402 assert "abc1234567890" in body
1403
1404
1405 @pytest.mark.anyio
1406 async def test_ui_release_list_page_has_tag_colour_coding(
1407 client: AsyncClient,
1408 db_session: AsyncSession,
1409 ) -> None:
1410 """Release list page SSR colour-codes tags: stable vs pre-release CSS classes."""
1411 repo_id = await _make_repo(db_session)
1412 db_session.add(MusehubRelease(
1413 repo_id=repo_id, tag="v1.0", title="Stable Release",
1414 body="", author="testuser", is_prerelease=False,
1415 ))
1416 db_session.add(MusehubRelease(
1417 repo_id=repo_id, tag="v2.0-beta", title="Beta Release",
1418 body="", author="testuser", is_prerelease=True,
1419 ))
1420 await db_session.commit()
1421 response = await client.get("/testuser/test-beats/releases")
1422 assert response.status_code == 200
1423 body = response.text
1424 assert "tag-stable" in body
1425 assert "tag-prerelease" in body
1426
1427
1428 @pytest.mark.anyio
1429 async def test_ui_release_detail_page_returns_200(
1430 client: AsyncClient,
1431 db_session: AsyncSession,
1432 ) -> None:
1433 """GET /{owner}/{slug}/releases/{tag} returns 200 HTML with download section."""
1434 repo_id = await _make_repo(db_session)
1435 release = MusehubRelease(
1436 repo_id=repo_id, tag="v1.0", title="Version 1.0",
1437 body="Initial release.", author="testuser",
1438 )
1439 db_session.add(release)
1440 await db_session.commit()
1441 response = await client.get("/testuser/test-beats/releases/v1.0")
1442 assert response.status_code == 200
1443 assert "text/html" in response.headers["content-type"]
1444 body = response.text
1445 assert "MuseHub" in body
1446 assert "Download" in body
1447 assert "v1.0" in body
1448
1449
1450 @pytest.mark.anyio
1451 async def test_ui_release_detail_has_comment_section(
1452 client: AsyncClient,
1453 db_session: AsyncSession,
1454 ) -> None:
1455 """Release detail page renders SSR release header and metadata."""
1456 repo_id = await _make_repo(db_session)
1457 release = MusehubRelease(
1458 repo_id=repo_id, tag="v1.0", title="Version 1.0",
1459 body="Initial release.", author="testuser",
1460 )
1461 db_session.add(release)
1462 await db_session.commit()
1463 response = await client.get("/testuser/test-beats/releases/v1.0")
1464 assert response.status_code == 200
1465 body = response.text
1466 assert "release-header" in body
1467 assert "release-title" in body
1468
1469
1470 @pytest.mark.anyio
1471 async def test_ui_release_detail_has_render_comments_js(
1472 client: AsyncClient,
1473 db_session: AsyncSession,
1474 ) -> None:
1475 """Release detail page renders SSR release notes section."""
1476 repo_id = await _make_repo(db_session)
1477 release = MusehubRelease(
1478 repo_id=repo_id, tag="v1.0", title="Version 1.0",
1479 body="Initial release.", author="testuser",
1480 )
1481 db_session.add(release)
1482 await db_session.commit()
1483 response = await client.get("/testuser/test-beats/releases/v1.0")
1484 assert response.status_code == 200
1485 body = response.text
1486 assert "Release Notes" in body
1487 assert "release-badges" in body
1488
1489
1490 @pytest.mark.anyio
1491 async def test_ui_release_detail_comment_uses_release_target_type(
1492 client: AsyncClient,
1493 db_session: AsyncSession,
1494 ) -> None:
1495 """Release detail page renders the reaction bar with release target type."""
1496 repo_id = await _make_repo(db_session)
1497 release = MusehubRelease(
1498 repo_id=repo_id, tag="v1.0", title="Version 1.0",
1499 body="Initial release.", author="testuser",
1500 )
1501 db_session.add(release)
1502 await db_session.commit()
1503 response = await client.get("/testuser/test-beats/releases/v1.0")
1504 assert response.status_code == 200
1505 body = response.text
1506 assert "release" in body
1507 assert "v1.0" in body
1508
1509
1510 @pytest.mark.anyio
1511 async def test_ui_release_detail_has_reply_thread_js(
1512 client: AsyncClient,
1513 db_session: AsyncSession,
1514 ) -> None:
1515 """Release detail page renders SSR author and date metadata."""
1516 repo_id = await _make_repo(db_session)
1517 release = MusehubRelease(
1518 repo_id=repo_id, tag="v1.0", title="Version 1.0",
1519 body="Initial release.", author="testuser",
1520 )
1521 db_session.add(release)
1522 await db_session.commit()
1523 response = await client.get("/testuser/test-beats/releases/v1.0")
1524 assert response.status_code == 200
1525 body = response.text
1526 assert "meta-label" in body
1527 assert "Author" in body
1528
1529
1530 @pytest.mark.anyio
1531 async def test_ui_repo_page_shows_releases_button(
1532 client: AsyncClient,
1533 db_session: AsyncSession,
1534 ) -> None:
1535 """GET /{repo_id} includes a Releases navigation button."""
1536 repo_id = await _make_repo(db_session)
1537 response = await client.get("/testuser/test-beats")
1538 assert response.status_code == 200
1539 body = response.text
1540 assert "releases" in body.lower()
1541
1542
1543 # ---------------------------------------------------------------------------
1544 # Global search UI page tests
1545 # ---------------------------------------------------------------------------
1546
1547
1548 @pytest.mark.anyio
1549 async def test_global_search_ui_page_returns_200(
1550 client: AsyncClient,
1551 db_session: AsyncSession,
1552 ) -> None:
1553 """GET /search returns 200 HTML (no auth required — HTML shell)."""
1554 response = await client.get("/search")
1555 assert response.status_code == 200
1556 assert "text/html" in response.headers["content-type"]
1557 body = response.text
1558 assert "Global Search" in body
1559 assert "MuseHub" in body
1560
1561
1562 @pytest.mark.anyio
1563 async def test_global_search_ui_page_no_auth_required(
1564 client: AsyncClient,
1565 db_session: AsyncSession,
1566 ) -> None:
1567 """GET /search must not return 401 — it is a static HTML shell."""
1568 response = await client.get("/search")
1569 assert response.status_code != 401
1570 assert response.status_code == 200
1571
1572
1573 # ---------------------------------------------------------------------------
1574 # Object listing endpoint tests (JSON, authed)
1575 # ---------------------------------------------------------------------------
1576
1577
1578 @pytest.mark.anyio
1579 async def test_list_objects_returns_empty_for_new_repo(
1580 client: AsyncClient,
1581 db_session: AsyncSession,
1582 auth_headers: dict[str, str],
1583 ) -> None:
1584 """GET /api/v1/repos/{repo_id}/objects returns empty list for new repo."""
1585 repo_id = await _make_repo(db_session)
1586 response = await client.get(
1587 f"/api/v1/repos/{repo_id}/objects",
1588 headers=auth_headers,
1589 )
1590 assert response.status_code == 200
1591 assert response.json()["objects"] == []
1592
1593
1594 @pytest.mark.anyio
1595 async def test_list_objects_requires_auth(
1596 client: AsyncClient,
1597 db_session: AsyncSession,
1598 ) -> None:
1599 """GET /api/v1/repos/{repo_id}/objects returns 401 without auth."""
1600 repo_id = await _make_repo(db_session)
1601 response = await client.get(f"/api/v1/repos/{repo_id}/objects")
1602 assert response.status_code == 401
1603
1604
1605 @pytest.mark.anyio
1606 async def test_list_objects_404_for_unknown_repo(
1607 client: AsyncClient,
1608 db_session: AsyncSession,
1609 auth_headers: dict[str, str],
1610 ) -> None:
1611 """GET /api/v1/repos/{unknown}/objects returns 404."""
1612 response = await client.get(
1613 "/api/v1/repos/does-not-exist/objects",
1614 headers=auth_headers,
1615 )
1616 assert response.status_code == 404
1617
1618
1619 @pytest.mark.anyio
1620 async def test_get_object_content_404_for_unknown_object(
1621 client: AsyncClient,
1622 db_session: AsyncSession,
1623 auth_headers: dict[str, str],
1624 ) -> None:
1625 """GET /api/v1/repos/{repo_id}/objects/{unknown}/content returns 404."""
1626 repo_id = await _make_repo(db_session)
1627 response = await client.get(
1628 f"/api/v1/repos/{repo_id}/objects/sha256:notexist/content",
1629 headers=auth_headers,
1630 )
1631 assert response.status_code == 404
1632
1633
1634 # ---------------------------------------------------------------------------
1635 # Credits UI page tests
1636 # DAG graph UI page tests
1637 # ---------------------------------------------------------------------------
1638
1639
1640 @pytest.mark.anyio
1641 async def test_credits_page_renders(
1642 client: AsyncClient,
1643 db_session: AsyncSession,
1644 ) -> None:
1645 """GET /{owner}/{repo_slug}/credits returns 200 HTML without requiring a JWT."""
1646 repo_id = await _make_repo(db_session)
1647 response = await client.get("/testuser/test-beats/credits")
1648 assert response.status_code == 200
1649 assert "text/html" in response.headers["content-type"]
1650 body = response.text
1651 assert "MuseHub" in body
1652 assert "Credits" in body
1653
1654
1655 @pytest.mark.anyio
1656
1657
1658 async def test_graph_page_renders(
1659 client: AsyncClient,
1660 db_session: AsyncSession,
1661 ) -> None:
1662 """GET /{repo_id}/graph returns 200 HTML without requiring a JWT."""
1663 repo_id = await _make_repo(db_session)
1664 response = await client.get("/testuser/test-beats/graph")
1665 assert response.status_code == 200
1666 assert "text/html" in response.headers["content-type"]
1667 body = response.text
1668 assert "MuseHub" in body
1669 assert "graph" in body.lower()
1670
1671
1672 # ---------------------------------------------------------------------------
1673 # Context viewer tests
1674 # ---------------------------------------------------------------------------
1675
1676 _FIXED_COMMIT_ID = "aabbccdd" * 8 # 64-char hex string
1677
1678
1679 async def _make_repo_with_commit(db_session: AsyncSession) -> tuple[str, str]:
1680 """Seed a repo with one commit and return (repo_id, commit_id)."""
1681 repo = MusehubRepo(
1682 name="jazz-context-test",
1683 owner="testuser",
1684 slug="jazz-context-test",
1685 visibility="private",
1686 owner_user_id="test-owner",
1687 )
1688 db_session.add(repo)
1689 await db_session.flush()
1690 await db_session.refresh(repo)
1691 repo_id = str(repo.repo_id)
1692
1693 commit = MusehubCommit(
1694 commit_id=_FIXED_COMMIT_ID,
1695 repo_id=repo_id,
1696 branch="main",
1697 parent_ids=[],
1698 message="Add bass and drums",
1699 author="test-musician",
1700 timestamp=datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc),
1701 )
1702 db_session.add(commit)
1703 await db_session.commit()
1704 return repo_id, _FIXED_COMMIT_ID
1705
1706
1707 @pytest.mark.anyio
1708 async def test_context_page_renders(
1709 client: AsyncClient,
1710 db_session: AsyncSession,
1711 ) -> None:
1712 """GET /{repo_id}/context/{ref} returns 200 HTML without auth."""
1713 repo_id, commit_id = await _make_repo_with_commit(db_session)
1714 response = await client.get(f"/testuser/jazz-context-test/context/{commit_id}")
1715 assert response.status_code == 200
1716 assert "text/html" in response.headers["content-type"]
1717 body = response.text
1718 assert "MuseHub" in body
1719 assert "context" in body.lower()
1720 assert repo_id[:8] in body
1721
1722
1723 @pytest.mark.anyio
1724 async def test_credits_json_response(
1725 client: AsyncClient,
1726 db_session: AsyncSession,
1727 auth_headers: dict[str, str],
1728 ) -> None:
1729 """GET /api/v1/repos/{repo_id}/credits returns JSON with required fields."""
1730 repo_id = await _make_repo(db_session)
1731 response = await client.get(
1732 f"/api/v1/repos/{repo_id}/credits",
1733 headers=auth_headers,
1734 )
1735 assert response.status_code == 200
1736 body = response.json()
1737 assert "repoId" in body
1738 assert "contributors" in body
1739 assert "sort" in body
1740 assert "totalContributors" in body
1741 assert body["repoId"] == repo_id
1742 assert isinstance(body["contributors"], list)
1743 assert body["sort"] == "count"
1744
1745
1746
1747 @pytest.mark.anyio
1748 async def test_context_json_response(
1749 client: AsyncClient,
1750 db_session: AsyncSession,
1751 auth_headers: dict[str, str],
1752 ) -> None:
1753 """GET /api/v1/repos/{repo_id}/context/{ref} returns MuseHubContextResponse."""
1754 repo_id, commit_id = await _make_repo_with_commit(db_session)
1755 response = await client.get(
1756 f"/api/v1/repos/{repo_id}/context/{commit_id}",
1757 headers=auth_headers,
1758 )
1759 assert response.status_code == 200
1760 body = response.json()
1761 assert "repoId" in body
1762
1763 assert body["repoId"] == repo_id
1764 assert body["currentBranch"] == "main"
1765 assert "headCommit" in body
1766 assert body["headCommit"]["commitId"] == commit_id
1767 assert body["headCommit"]["author"] == "test-musician"
1768 assert "musicalState" in body
1769 assert "history" in body
1770 assert "missingElements" in body
1771 assert "suggestions" in body
1772
1773
1774 @pytest.mark.anyio
1775 async def test_credits_empty_state_json(
1776 client: AsyncClient,
1777 db_session: AsyncSession,
1778 auth_headers: dict[str, str],
1779 ) -> None:
1780 """Repo with no commits returns empty contributors list and totalContributors=0."""
1781 repo_id = await _make_repo(db_session)
1782 response = await client.get(
1783 f"/api/v1/repos/{repo_id}/credits",
1784 headers=auth_headers,
1785 )
1786 assert response.status_code == 200
1787 body = response.json()
1788 assert body["contributors"] == []
1789 assert body["totalContributors"] == 0
1790
1791
1792 @pytest.mark.anyio
1793 async def test_context_includes_musical_state(
1794 client: AsyncClient,
1795 db_session: AsyncSession,
1796 auth_headers: dict[str, str],
1797 ) -> None:
1798
1799 """Context response includes musicalState with an activeTracks field."""
1800 repo_id, commit_id = await _make_repo_with_commit(db_session)
1801 response = await client.get(
1802 f"/api/v1/repos/{repo_id}/context/{commit_id}",
1803 headers=auth_headers,
1804 )
1805 assert response.status_code == 200
1806 musical_state = response.json()["musicalState"]
1807 assert "activeTracks" in musical_state
1808 assert isinstance(musical_state["activeTracks"], list)
1809 # Dimensions requiring MIDI analysis are None at this stage
1810 assert musical_state["key"] is None
1811 assert musical_state["tempoBpm"] is None
1812
1813
1814 @pytest.mark.anyio
1815 async def test_context_unknown_ref_404(
1816 client: AsyncClient,
1817 db_session: AsyncSession,
1818 auth_headers: dict[str, str],
1819 ) -> None:
1820 """GET /api/v1/repos/{repo_id}/context/{ref} returns 404 for unknown ref."""
1821 repo_id = await _make_repo(db_session)
1822 response = await client.get(
1823 f"/api/v1/repos/{repo_id}/context/deadbeef" + "0" * 56,
1824 headers=auth_headers,
1825 )
1826 assert response.status_code == 404
1827
1828
1829 @pytest.mark.anyio
1830 async def test_context_unknown_repo_404(
1831 client: AsyncClient,
1832 db_session: AsyncSession,
1833 auth_headers: dict[str, str],
1834 ) -> None:
1835 """GET /api/v1/repos/{unknown}/context/{ref} returns 404 for unknown repo."""
1836 response = await client.get(
1837 "/api/v1/repos/ghost-repo/context/deadbeef" + "0" * 56,
1838 headers=auth_headers,
1839 )
1840 assert response.status_code == 404
1841
1842
1843 @pytest.mark.anyio
1844 async def test_context_requires_auth(
1845 client: AsyncClient,
1846 db_session: AsyncSession,
1847 ) -> None:
1848 """GET /api/v1/repos/{repo_id}/context/{ref} returns 401 without auth."""
1849 repo_id = await _make_repo(db_session)
1850 response = await client.get(
1851 f"/api/v1/repos/{repo_id}/context/deadbeef" + "0" * 56,
1852 )
1853 assert response.status_code == 401
1854
1855
1856 @pytest.mark.anyio
1857 async def test_context_page_no_auth_required(
1858 client: AsyncClient,
1859 db_session: AsyncSession,
1860 ) -> None:
1861 """The context UI page must be accessible without a JWT (HTML shell handles auth)."""
1862 repo_id, commit_id = await _make_repo_with_commit(db_session)
1863 response = await client.get(f"/testuser/jazz-context-test/context/{commit_id}")
1864 assert response.status_code != 401
1865 assert response.status_code == 200
1866
1867
1868 # ---------------------------------------------------------------------------
1869 # Context page additional tests
1870 # ---------------------------------------------------------------------------
1871
1872
1873 @pytest.mark.anyio
1874 async def test_context_page_contains_agent_explainer(
1875 client: AsyncClient,
1876 db_session: AsyncSession,
1877 ) -> None:
1878 """Context viewer page SSR: ref prefix and Musical Context heading appear in HTML.
1879
1880 The context page is now fully SSR — data is server-rendered rather than
1881 fetched client-side. The ref prefix must appear in the breadcrumb/badge
1882 and the Musical Context heading must be present.
1883 """
1884 repo_id, commit_id = await _make_repo_with_commit(db_session)
1885 response = await client.get(f"/testuser/jazz-context-test/context/{commit_id}")
1886 assert response.status_code == 200
1887 body = response.text
1888 assert "Musical Context" in body
1889 assert commit_id[:8] in body
1890
1891
1892 # ---------------------------------------------------------------------------
1893 # Embed player route tests
1894 # ---------------------------------------------------------------------------
1895
1896
1897 @pytest.mark.anyio
1898 async def test_embed_page_renders(
1899 client: AsyncClient,
1900 db_session: AsyncSession,
1901 ) -> None:
1902 """GET /{repo_id}/embed/{ref} returns 200 HTML."""
1903 repo_id = await _make_repo(db_session)
1904 ref = "abc1234567890abcdef"
1905 response = await client.get(f"/testuser/test-beats/embed/{ref}")
1906 assert response.status_code == 200
1907 assert "text/html" in response.headers["content-type"]
1908
1909
1910 @pytest.mark.anyio
1911 async def test_embed_no_auth_required(
1912 client: AsyncClient,
1913 db_session: AsyncSession,
1914 ) -> None:
1915 """Embed page must be accessible without an Authorization header (public embedding)."""
1916 repo_id = await _make_repo(db_session)
1917 ref = "deadbeef1234"
1918 response = await client.get(f"/testuser/test-beats/embed/{ref}")
1919 assert response.status_code != 401
1920 assert response.status_code == 200
1921
1922
1923 @pytest.mark.anyio
1924 async def test_embed_page_x_frame_options(
1925 client: AsyncClient,
1926 db_session: AsyncSession,
1927 ) -> None:
1928 """Embed page must set X-Frame-Options: ALLOWALL to permit cross-origin framing."""
1929 repo_id = await _make_repo(db_session)
1930 ref = "cafebabe1234"
1931 response = await client.get(f"/testuser/test-beats/embed/{ref}")
1932 assert response.status_code == 200
1933 assert response.headers.get("x-frame-options") == "ALLOWALL"
1934
1935
1936 @pytest.mark.anyio
1937 async def test_embed_page_contains_player_ui(
1938 client: AsyncClient,
1939 db_session: AsyncSession,
1940 ) -> None:
1941 """Embed page HTML must contain player elements: play button, progress bar, and MuseHub link."""
1942 repo_id = await _make_repo(db_session)
1943 ref = "feedface0123456789ab"
1944 response = await client.get(f"/testuser/test-beats/embed/{ref}")
1945 assert response.status_code == 200
1946 body = response.text
1947 assert "play-btn" in body
1948 assert "progress-bar" in body
1949 assert "View on MuseHub" in body
1950 assert "audio" in body
1951 assert repo_id in body
1952
1953 # ---------------------------------------------------------------------------
1954 # Groove check page and endpoint tests
1955
1956 # ---------------------------------------------------------------------------
1957
1958
1959 @pytest.mark.anyio
1960 async def test_groove_check_page_renders(
1961 client: AsyncClient,
1962 db_session: AsyncSession,
1963 ) -> None:
1964 """GET /{repo_id}/groove-check returns 200 HTML without requiring a JWT."""
1965 repo_id = await _make_repo(db_session)
1966 response = await client.get("/testuser/test-beats/groove-check")
1967 assert response.status_code == 200
1968 assert "text/html" in response.headers["content-type"]
1969 body = response.text
1970 assert "MuseHub" in body
1971 assert "Groove Check" in body
1972
1973
1974 @pytest.mark.anyio
1975 async def test_credits_page_contains_json_ld_injection_slug_route(
1976 client: AsyncClient,
1977 db_session: AsyncSession,
1978 ) -> None:
1979 """Credits page embeds JSON-LD injection logic via slug route."""
1980 repo_id = await _make_repo(db_session)
1981 response = await client.get("/testuser/test-beats/credits")
1982 assert response.status_code == 200
1983 body = response.text
1984 assert "application/ld+json" in body
1985 assert "schema.org" in body
1986 assert "MusicComposition" in body
1987
1988
1989 @pytest.mark.anyio
1990 async def test_credits_page_contains_sort_options_slug_route(
1991 client: AsyncClient,
1992 db_session: AsyncSession,
1993 ) -> None:
1994 """Credits page includes sort dropdown via slug route."""
1995 repo_id = await _make_repo(db_session)
1996 response = await client.get("/testuser/test-beats/credits")
1997 assert response.status_code == 200
1998 body = response.text
1999 assert "Most prolific" in body
2000 assert "Most recent" in body
2001 assert "A" in body # "A – Z" option
2002
2003
2004 @pytest.mark.anyio
2005 async def test_credits_empty_state_message_in_page_slug_route(
2006 client: AsyncClient,
2007 db_session: AsyncSession,
2008 ) -> None:
2009 """Credits page renders the SSR empty state when there are no contributors."""
2010 repo_id = await _make_repo(db_session)
2011 response = await client.get("/testuser/test-beats/credits")
2012 assert response.status_code == 200
2013 body = response.text
2014 assert "No credits yet" in body
2015
2016
2017 @pytest.mark.anyio
2018 async def test_credits_no_auth_required_slug_route(
2019 client: AsyncClient,
2020 db_session: AsyncSession,
2021 ) -> None:
2022 """Credits page must be accessible without an Authorization header via slug route."""
2023 repo_id = await _make_repo(db_session)
2024 response = await client.get("/testuser/test-beats/credits")
2025 assert response.status_code == 200
2026 assert response.status_code != 401
2027
2028
2029 @pytest.mark.anyio
2030 async def test_credits_page_contains_avatar_functions(
2031 client: AsyncClient,
2032 db_session: AsyncSession,
2033 ) -> None:
2034 """Credits page renders the SSR credits layout with sort controls."""
2035 await _make_repo(db_session)
2036 response = await client.get("/testuser/test-beats/credits")
2037 assert response.status_code == 200
2038 body = response.text
2039 assert "🎶 Credits" in body
2040 assert "filter-select" in body
2041
2042
2043 @pytest.mark.anyio
2044 async def test_credits_page_contains_fetch_profile_function(
2045 client: AsyncClient,
2046 db_session: AsyncSession,
2047 ) -> None:
2048 """Credits page renders SSR sort controls for contributor ordering."""
2049 await _make_repo(db_session)
2050 response = await client.get("/testuser/test-beats/credits")
2051 assert response.status_code == 200
2052 body = response.text
2053 assert "Most prolific" in body
2054 assert "Most recent" in body
2055
2056
2057 @pytest.mark.anyio
2058 async def test_credits_page_contains_profile_link_pattern(
2059 client: AsyncClient,
2060 db_session: AsyncSession,
2061 ) -> None:
2062 """Credits page schema says profile links use /users/ — verified via template."""
2063 await _make_repo(db_session)
2064 response = await client.get("/testuser/test-beats/credits")
2065 assert response.status_code == 200
2066 body = response.text
2067 # Always-rendered elements (profile links only appear with contributors)
2068 assert "🎶 Credits" in body
2069 assert "contributor" in body
2070
2071
2072 @pytest.mark.anyio
2073 async def test_groove_check_page_no_auth_required(
2074 client: AsyncClient,
2075 db_session: AsyncSession,
2076 ) -> None:
2077 """Groove check UI page must be accessible without an Authorization header (HTML shell)."""
2078 repo_id = await _make_repo(db_session)
2079 response = await client.get("/testuser/test-beats/groove-check")
2080 assert response.status_code != 401
2081 assert response.status_code == 200
2082
2083
2084 # ---------------------------------------------------------------------------
2085 # Object listing endpoint tests (JSON, authed)
2086 # ---------------------------------------------------------------------------
2087
2088
2089 @pytest.mark.anyio
2090 async def test_groove_check_page_contains_chart_js(
2091 client: AsyncClient,
2092 db_session: AsyncSession,
2093 ) -> None:
2094 """Groove check page embeds the SVG chart rendering JavaScript."""
2095 repo_id = await _make_repo(db_session)
2096 response = await client.get("/testuser/test-beats/groove-check")
2097 assert response.status_code == 200
2098 body = response.text
2099 assert "renderGrooveChart" in body
2100 assert "grooveScore" in body
2101 assert "driftDelta" in body
2102
2103
2104 @pytest.mark.anyio
2105 async def test_groove_check_page_contains_status_badges(
2106 client: AsyncClient,
2107 db_session: AsyncSession,
2108 ) -> None:
2109 """Groove check page HTML includes OK / WARN / FAIL status badge rendering."""
2110 repo_id = await _make_repo(db_session)
2111 response = await client.get("/testuser/test-beats/groove-check")
2112 assert response.status_code == 200
2113 body = response.text
2114 assert "statusBadge" in body
2115 assert "WARN" in body
2116 assert "FAIL" in body
2117
2118
2119 @pytest.mark.anyio
2120 async def test_groove_check_page_includes_token_form(
2121 client: AsyncClient,
2122 db_session: AsyncSession,
2123 ) -> None:
2124 """Groove check page embeds the JWT token input form so visitors can authenticate."""
2125 repo_id = await _make_repo(db_session)
2126 response = await client.get("/testuser/test-beats/groove-check")
2127 assert response.status_code == 200
2128 body = response.text
2129 assert "token-form" in body
2130 assert "token-input" in body
2131
2132
2133 @pytest.mark.anyio
2134 async def test_groove_check_endpoint_returns_json(
2135 client: AsyncClient,
2136 db_session: AsyncSession,
2137 auth_headers: dict[str, str],
2138 ) -> None:
2139 """GET /api/v1/repos/{repo_id}/groove-check returns JSON with required fields."""
2140 repo_id = await _make_repo(db_session)
2141 response = await client.get(
2142 f"/api/v1/repos/{repo_id}/groove-check",
2143 headers=auth_headers,
2144 )
2145 assert response.status_code == 200
2146 body = response.json()
2147 assert "commitRange" in body
2148 assert "threshold" in body
2149 assert "totalCommits" in body
2150 assert "flaggedCommits" in body
2151 assert "worstCommit" in body
2152 assert "entries" in body
2153 assert isinstance(body["entries"], list)
2154
2155
2156 @pytest.mark.anyio
2157 async def test_graph_no_auth_required(
2158 client: AsyncClient,
2159 db_session: AsyncSession,
2160 ) -> None:
2161 """Graph page must be accessible without an Authorization header (HTML shell)."""
2162 repo_id = await _make_repo(db_session)
2163 response = await client.get("/testuser/test-beats/graph")
2164 assert response.status_code == 200
2165 assert response.status_code != 401
2166
2167
2168 async def test_groove_check_endpoint_entries_have_required_fields(
2169 client: AsyncClient,
2170 db_session: AsyncSession,
2171 auth_headers: dict[str, str],
2172 ) -> None:
2173 """Groove check endpoint returns GrooveCheckResponse shape (stub: empty entries)."""
2174 repo_id = await _make_repo(db_session)
2175 response = await client.get(
2176 f"/api/v1/repos/{repo_id}/groove-check?limit=5",
2177 headers=auth_headers,
2178 )
2179 assert response.status_code == 200
2180 body = response.json()
2181 # Groove-check is a stub (TODO: integrate cgcardona/muse service API).
2182 # Validate the response envelope shape rather than entry counts.
2183 assert "totalCommits" in body
2184 assert "flaggedCommits" in body
2185 assert "entries" in body
2186 assert isinstance(body["entries"], list)
2187 assert "commitRange" in body
2188 assert "threshold" in body
2189
2190
2191 @pytest.mark.anyio
2192 async def test_groove_check_endpoint_requires_auth(
2193 client: AsyncClient,
2194 db_session: AsyncSession,
2195 ) -> None:
2196 """GET /api/v1/repos/{repo_id}/groove-check returns 401 without auth."""
2197 repo_id = await _make_repo(db_session)
2198 response = await client.get(f"/api/v1/repos/{repo_id}/groove-check")
2199 assert response.status_code == 401
2200
2201
2202 @pytest.mark.anyio
2203 async def test_groove_check_endpoint_404_for_unknown_repo(
2204 client: AsyncClient,
2205 db_session: AsyncSession,
2206 auth_headers: dict[str, str],
2207 ) -> None:
2208 """GET /api/v1/repos/{unknown}/groove-check returns 404."""
2209 response = await client.get(
2210 "/api/v1/repos/does-not-exist/groove-check",
2211 headers=auth_headers,
2212 )
2213 assert response.status_code == 404
2214
2215
2216 @pytest.mark.anyio
2217 async def test_groove_check_endpoint_respects_limit(
2218 client: AsyncClient,
2219 db_session: AsyncSession,
2220 auth_headers: dict[str, str],
2221 ) -> None:
2222 """Groove check endpoint returns at most ``limit`` entries."""
2223 repo_id = await _make_repo(db_session)
2224 response = await client.get(
2225 f"/api/v1/repos/{repo_id}/groove-check?limit=3",
2226 headers=auth_headers,
2227 )
2228 assert response.status_code == 200
2229 body = response.json()
2230 assert body["totalCommits"] <= 3
2231 assert len(body["entries"]) <= 3
2232
2233
2234 @pytest.mark.anyio
2235 async def test_groove_check_endpoint_custom_threshold(
2236 client: AsyncClient,
2237 db_session: AsyncSession,
2238 auth_headers: dict[str, str],
2239 ) -> None:
2240 """Groove check endpoint accepts a custom threshold parameter."""
2241 repo_id = await _make_repo(db_session)
2242 response = await client.get(
2243 f"/api/v1/repos/{repo_id}/groove-check?threshold=0.05",
2244 headers=auth_headers,
2245 )
2246 assert response.status_code == 200
2247 body = response.json()
2248 assert abs(body["threshold"] - 0.05) < 1e-9
2249
2250
2251 @pytest.mark.anyio
2252 async def test_repo_page_contains_groove_check_link(
2253 client: AsyncClient,
2254 db_session: AsyncSession,
2255 ) -> None:
2256 """Repo landing page navigation includes a Groove Check link."""
2257 repo_id = await _make_repo(db_session)
2258 response = await client.get("/testuser/test-beats")
2259 assert response.status_code == 200
2260 body = response.text
2261 assert "groove-check" in body
2262
2263
2264 # ---------------------------------------------------------------------------
2265 # User profile page tests (— pre-existing from dev, fixed here)
2266 # ---------------------------------------------------------------------------
2267
2268
2269 @pytest.mark.anyio
2270 async def test_profile_page_renders(
2271 client: AsyncClient,
2272 db_session: AsyncSession,
2273 ) -> None:
2274 """GET /users/{username} returns 200 HTML for a known profile."""
2275 await _make_profile(db_session, "rockstar")
2276 response = await client.get("/rockstar")
2277 assert response.status_code == 200
2278 assert "text/html" in response.headers["content-type"]
2279 body = response.text
2280 assert "MuseHub" in body
2281 assert "@rockstar" in body
2282 # Contribution graph JS moved to app.js (TypeScript module); check page dispatch instead
2283 assert '"page": "user-profile"' in body
2284
2285
2286 @pytest.mark.anyio
2287 async def test_profile_no_auth_required_ui(
2288 client: AsyncClient,
2289 db_session: AsyncSession,
2290 ) -> None:
2291 """Profile UI page is publicly accessible without a JWT (returns 200, not 401)."""
2292 await _make_profile(db_session, "public-user")
2293 response = await client.get("/public-user")
2294 assert response.status_code == 200
2295 assert response.status_code != 401
2296
2297
2298 @pytest.mark.anyio
2299 async def test_profile_unknown_user_404(
2300 client: AsyncClient,
2301 db_session: AsyncSession,
2302 ) -> None:
2303 """GET /api/v1/users/{unknown} returns 404 for a non-existent profile."""
2304 response = await client.get("/api/v1/users/does-not-exist-xyz")
2305 assert response.status_code == 404
2306
2307
2308 @pytest.mark.anyio
2309 async def test_profile_json_response(
2310 client: AsyncClient,
2311 db_session: AsyncSession,
2312 ) -> None:
2313 """GET /api/v1/users/{username} returns a valid JSON profile with required fields."""
2314 await _make_profile(db_session, "jazzmaster")
2315 response = await client.get("/api/v1/users/jazzmaster")
2316 assert response.status_code == 200
2317 data = response.json()
2318 assert data["username"] == "jazzmaster"
2319 assert "repos" in data
2320 assert "contributionGraph" in data
2321 assert "sessionCredits" in data
2322 assert isinstance(data["sessionCredits"], int)
2323 assert isinstance(data["contributionGraph"], list)
2324
2325
2326 @pytest.mark.anyio
2327 async def test_profile_lists_repos(
2328 client: AsyncClient,
2329 db_session: AsyncSession,
2330 ) -> None:
2331 """GET /api/v1/users/{username} includes public repos in the response."""
2332 await _make_profile(db_session, "beatmaker")
2333 repo_id = await _make_public_repo(db_session)
2334 response = await client.get("/api/v1/users/beatmaker")
2335 assert response.status_code == 200
2336 data = response.json()
2337 repo_ids = [r["repoId"] for r in data["repos"]]
2338 assert repo_id in repo_ids
2339
2340
2341 @pytest.mark.anyio
2342 async def test_profile_create_and_update(
2343 client: AsyncClient,
2344 db_session: AsyncSession,
2345 auth_headers: dict[str, str],
2346 ) -> None:
2347 """POST /api/v1/users creates a profile; PUT updates it."""
2348 # Create profile
2349 resp = await client.post(
2350 "/api/v1/users",
2351 json={"username": "newartist", "bio": "Initial bio"},
2352 headers=auth_headers,
2353 )
2354 assert resp.status_code == 201
2355 data = resp.json()
2356 assert data["username"] == "newartist"
2357 assert data["bio"] == "Initial bio"
2358
2359 # Update profile
2360 resp2 = await client.put(
2361 "/api/v1/users/newartist",
2362 json={"bio": "Updated bio"},
2363 headers=auth_headers,
2364 )
2365 assert resp2.status_code == 200
2366 assert resp2.json()["bio"] == "Updated bio"
2367
2368
2369 @pytest.mark.anyio
2370 async def test_profile_create_duplicate_username_409(
2371 client: AsyncClient,
2372 db_session: AsyncSession,
2373 auth_headers: dict[str, str],
2374 ) -> None:
2375 """POST /api/v1/users returns 409 when username is already taken."""
2376 await _make_profile(db_session, "takenname")
2377 resp = await client.post(
2378 "/api/v1/users",
2379 json={"username": "takenname"},
2380 headers=auth_headers,
2381 )
2382 assert resp.status_code == 409
2383
2384
2385 @pytest.mark.anyio
2386 async def test_profile_update_403_for_wrong_owner(
2387 client: AsyncClient,
2388 db_session: AsyncSession,
2389 auth_headers: dict[str, str],
2390 ) -> None:
2391 """PUT /api/v1/users/{username} returns 403 when caller doesn't own the profile."""
2392 # Create a profile owned by a DIFFERENT user
2393 other_profile = MusehubProfile(
2394 user_id="different-user-id-999",
2395 username="someoneelse",
2396 bio="not yours",
2397 pinned_repo_ids=[],
2398 )
2399 db_session.add(other_profile)
2400 await db_session.commit()
2401
2402 resp = await client.put(
2403 "/api/v1/users/someoneelse",
2404 json={"bio": "hijacked"},
2405 headers=auth_headers,
2406 )
2407 assert resp.status_code == 403
2408
2409
2410 @pytest.mark.anyio
2411 async def test_profile_page_unknown_user_renders_404_inline(
2412 client: AsyncClient,
2413 db_session: AsyncSession,
2414 ) -> None:
2415 """GET /users/{unknown} returns 200 HTML (JS renders 404 inline)."""
2416 response = await client.get("/ghost-user-xyz")
2417 # The HTML shell always returns 200 — the JS fetches and handles the API 404
2418 assert response.status_code == 200
2419 assert "text/html" in response.headers["content-type"]
2420
2421
2422 # ---------------------------------------------------------------------------
2423 # Forked repos endpoint tests
2424 # ---------------------------------------------------------------------------
2425
2426
2427 @pytest.mark.anyio
2428 async def test_profile_forked_repos_empty_list(
2429 client: AsyncClient,
2430 db_session: AsyncSession,
2431 ) -> None:
2432 """GET /api/v1/users/{username}/forks returns empty list when user has no forks."""
2433 await _make_profile(db_session, "freshuser")
2434 response = await client.get("/api/v1/users/freshuser/forks")
2435 assert response.status_code == 200
2436 data = response.json()
2437 assert data["forks"] == []
2438 assert data["total"] == 0
2439
2440
2441 @pytest.mark.anyio
2442 async def test_profile_forked_repos_returns_forks(
2443 client: AsyncClient,
2444 db_session: AsyncSession,
2445 ) -> None:
2446 """GET /api/v1/users/{username}/forks returns forked repos with source attribution."""
2447 await _make_profile(db_session, "forkuser")
2448
2449 # Seed a source repo owned by another user
2450 source = MusehubRepo(
2451 name="original-track",
2452 owner="original-owner",
2453 slug="original-track",
2454 visibility="public",
2455 owner_user_id="original-owner-id",
2456 )
2457 db_session.add(source)
2458 await db_session.commit()
2459 await db_session.refresh(source)
2460
2461 # Seed the fork repo owned by forkuser
2462 fork_repo = MusehubRepo(
2463 name="original-track",
2464 owner="forkuser",
2465 slug="original-track",
2466 visibility="public",
2467 owner_user_id=_TEST_USER_ID,
2468 )
2469 db_session.add(fork_repo)
2470 await db_session.commit()
2471 await db_session.refresh(fork_repo)
2472
2473 # Seed the fork relationship
2474 fork = MusehubFork(
2475 source_repo_id=source.repo_id,
2476 fork_repo_id=fork_repo.repo_id,
2477 forked_by="forkuser",
2478 )
2479 db_session.add(fork)
2480 await db_session.commit()
2481
2482 response = await client.get("/api/v1/users/forkuser/forks")
2483 assert response.status_code == 200
2484 data = response.json()
2485 assert data["total"] == 1
2486 assert len(data["forks"]) == 1
2487 entry = data["forks"][0]
2488 assert entry["sourceOwner"] == "original-owner"
2489 assert entry["sourceSlug"] == "original-track"
2490 assert entry["forkRepo"]["owner"] == "forkuser"
2491 assert entry["forkRepo"]["slug"] == "original-track"
2492 assert "forkId" in entry
2493 assert "forkedAt" in entry
2494
2495
2496 @pytest.mark.anyio
2497 async def test_profile_forked_repos_404_for_unknown_user(
2498 client: AsyncClient,
2499 db_session: AsyncSession,
2500 ) -> None:
2501 """GET /api/v1/users/{unknown}/forks returns 404 when user doesn't exist."""
2502 response = await client.get("/api/v1/users/ghost-no-profile/forks")
2503 assert response.status_code == 404
2504
2505
2506 @pytest.mark.anyio
2507 async def test_profile_forked_repos_no_auth_required(
2508 client: AsyncClient,
2509 db_session: AsyncSession,
2510 ) -> None:
2511 """GET /api/v1/users/{username}/forks is publicly accessible without a JWT."""
2512 await _make_profile(db_session, "public-forkuser")
2513 response = await client.get("/api/v1/users/public-forkuser/forks")
2514 assert response.status_code == 200
2515 assert response.status_code != 401
2516
2517
2518 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
2519 @pytest.mark.anyio
2520 async def test_profile_page_has_forked_section_js(
2521 client: AsyncClient,
2522 db_session: AsyncSession,
2523 ) -> None:
2524 """Profile HTML page includes the forked repos JS (loadForkedRepos, forked-section)."""
2525 await _make_profile(db_session, "jsforkuser")
2526 response = await client.get("/jsforkuser")
2527 assert response.status_code == 200
2528 body = response.text
2529 assert "loadForkedRepos" in body
2530 assert "forked-section" in body
2531 assert "API_FORKS" in body
2532 assert "forked from" in body
2533
2534
2535 # ---------------------------------------------------------------------------
2536 # Starred repos tab
2537 # ---------------------------------------------------------------------------
2538
2539
2540 @pytest.mark.anyio
2541 async def test_profile_starred_repos_empty_list(
2542 client: AsyncClient,
2543 db_session: AsyncSession,
2544 ) -> None:
2545 """GET /api/v1/users/{username}/starred returns empty list when user has no stars."""
2546 await _make_profile(db_session, "freshstaruser")
2547 response = await client.get("/api/v1/users/freshstaruser/starred")
2548 assert response.status_code == 200
2549 data = response.json()
2550 assert data["starred"] == []
2551 assert data["total"] == 0
2552
2553
2554 @pytest.mark.anyio
2555 async def test_profile_starred_repos_returns_starred(
2556 client: AsyncClient,
2557 db_session: AsyncSession,
2558 ) -> None:
2559 """GET /api/v1/users/{username}/starred returns starred repos with full metadata."""
2560 await _make_profile(db_session, "stargazeruser")
2561
2562 repo = MusehubRepo(
2563 name="awesome-groove",
2564 owner="someartist",
2565 slug="awesome-groove",
2566 visibility="public",
2567 owner_user_id="someartist-id",
2568 description="A great groove",
2569 key_signature="C major",
2570 tempo_bpm=120,
2571 )
2572 db_session.add(repo)
2573 await db_session.commit()
2574 await db_session.refresh(repo)
2575
2576 star = MusehubStar(
2577 repo_id=repo.repo_id,
2578 user_id=_TEST_USER_ID,
2579 )
2580 db_session.add(star)
2581 await db_session.commit()
2582
2583 response = await client.get("/api/v1/users/stargazeruser/starred")
2584 assert response.status_code == 200
2585 data = response.json()
2586 assert data["total"] == 1
2587 assert len(data["starred"]) == 1
2588 entry = data["starred"][0]
2589 assert entry["repo"]["owner"] == "someartist"
2590 assert entry["repo"]["slug"] == "awesome-groove"
2591 assert entry["repo"]["description"] == "A great groove"
2592 assert "starId" in entry
2593 assert "starredAt" in entry
2594
2595
2596 @pytest.mark.anyio
2597 async def test_profile_starred_repos_404_for_unknown_user(
2598 client: AsyncClient,
2599 db_session: AsyncSession,
2600 ) -> None:
2601 """GET /api/v1/users/{unknown}/starred returns 404 when user doesn't exist."""
2602 response = await client.get("/api/v1/users/ghost-no-star-profile/starred")
2603 assert response.status_code == 404
2604
2605
2606 @pytest.mark.anyio
2607 async def test_profile_starred_repos_no_auth_required(
2608 client: AsyncClient,
2609 db_session: AsyncSession,
2610 ) -> None:
2611 """GET /api/v1/users/{username}/starred is publicly accessible without a JWT."""
2612 await _make_profile(db_session, "public-staruser")
2613 response = await client.get("/api/v1/users/public-staruser/starred")
2614 assert response.status_code == 200
2615 assert response.status_code != 401
2616
2617
2618 @pytest.mark.anyio
2619 async def test_profile_starred_repos_ordered_newest_first(
2620 client: AsyncClient,
2621 db_session: AsyncSession,
2622 ) -> None:
2623 """GET /api/v1/users/{username}/starred returns stars newest first."""
2624 from datetime import timezone
2625
2626 await _make_profile(db_session, "multistaruser")
2627
2628 repo_a = MusehubRepo(
2629 name="track-alpha", owner="artist-a", slug="track-alpha",
2630 visibility="public", owner_user_id="artist-a-id",
2631 )
2632 repo_b = MusehubRepo(
2633 name="track-beta", owner="artist-b", slug="track-beta",
2634 visibility="public", owner_user_id="artist-b-id",
2635 )
2636 db_session.add_all([repo_a, repo_b])
2637 await db_session.commit()
2638 await db_session.refresh(repo_a)
2639 await db_session.refresh(repo_b)
2640
2641 import datetime as dt
2642 star_a = MusehubStar(
2643 repo_id=repo_a.repo_id,
2644 user_id=_TEST_USER_ID,
2645 created_at=dt.datetime(2024, 1, 1, tzinfo=timezone.utc),
2646 )
2647 star_b = MusehubStar(
2648 repo_id=repo_b.repo_id,
2649 user_id=_TEST_USER_ID,
2650 created_at=dt.datetime(2024, 6, 1, tzinfo=timezone.utc),
2651 )
2652 db_session.add_all([star_a, star_b])
2653 await db_session.commit()
2654
2655 response = await client.get("/api/v1/users/multistaruser/starred")
2656 assert response.status_code == 200
2657 data = response.json()
2658 assert data["total"] == 2
2659 # newest first: star_b (June) before star_a (January)
2660 assert data["starred"][0]["repo"]["slug"] == "track-beta"
2661 assert data["starred"][1]["repo"]["slug"] == "track-alpha"
2662
2663
2664 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
2665 @pytest.mark.anyio
2666 async def test_profile_page_has_starred_section_js(
2667 client: AsyncClient,
2668 db_session: AsyncSession,
2669 ) -> None:
2670 """Profile HTML page includes the starred repos JS (loadStarredRepos, starred-section)."""
2671 await _make_profile(db_session, "jsstaruser")
2672 response = await client.get("/jsstaruser")
2673 assert response.status_code == 200
2674 body = response.text
2675 assert "loadStarredRepos" in body
2676 assert "starred-section" in body
2677 assert "API_STARRED" in body
2678 assert "starredRepoCardHtml" in body
2679
2680
2681 # ---------------------------------------------------------------------------
2682 # Watched repos tab
2683 # ---------------------------------------------------------------------------
2684
2685
2686 @pytest.mark.anyio
2687 async def test_profile_watched_repos_empty_list(
2688 client: AsyncClient,
2689 db_session: AsyncSession,
2690 ) -> None:
2691 """GET /api/v1/users/{username}/watched returns empty list when user watches nothing."""
2692 await _make_profile(db_session, "freshwatchuser")
2693 response = await client.get("/api/v1/users/freshwatchuser/watched")
2694 assert response.status_code == 200
2695 data = response.json()
2696 assert data["watched"] == []
2697 assert data["total"] == 0
2698
2699
2700 @pytest.mark.anyio
2701 async def test_profile_watched_repos_returns_watched(
2702 client: AsyncClient,
2703 db_session: AsyncSession,
2704 ) -> None:
2705 """GET /api/v1/users/{username}/watched returns watched repos with full metadata."""
2706 await _make_profile(db_session, "watcheruser")
2707
2708 repo = MusehubRepo(
2709 name="cool-composition",
2710 owner="composer",
2711 slug="cool-composition",
2712 visibility="public",
2713 owner_user_id="composer-id",
2714 description="A cool composition",
2715 key_signature="G minor",
2716 tempo_bpm=90,
2717 )
2718 db_session.add(repo)
2719 await db_session.commit()
2720 await db_session.refresh(repo)
2721
2722 watch = MusehubWatch(
2723 repo_id=repo.repo_id,
2724 user_id=_TEST_USER_ID,
2725 )
2726 db_session.add(watch)
2727 await db_session.commit()
2728
2729 response = await client.get("/api/v1/users/watcheruser/watched")
2730 assert response.status_code == 200
2731 data = response.json()
2732 assert data["total"] == 1
2733 assert len(data["watched"]) == 1
2734 entry = data["watched"][0]
2735 assert entry["repo"]["owner"] == "composer"
2736 assert entry["repo"]["slug"] == "cool-composition"
2737 assert entry["repo"]["description"] == "A cool composition"
2738 assert "watchId" in entry
2739 assert "watchedAt" in entry
2740
2741
2742 @pytest.mark.anyio
2743 async def test_profile_watched_repos_404_for_unknown_user(
2744 client: AsyncClient,
2745 db_session: AsyncSession,
2746 ) -> None:
2747 """GET /api/v1/users/{unknown}/watched returns 404 when user doesn't exist."""
2748 response = await client.get("/api/v1/users/ghost-no-watch-profile/watched")
2749 assert response.status_code == 404
2750
2751
2752 @pytest.mark.anyio
2753 async def test_profile_watched_repos_no_auth_required(
2754 client: AsyncClient,
2755 db_session: AsyncSession,
2756 ) -> None:
2757 """GET /api/v1/users/{username}/watched is publicly accessible without a JWT."""
2758 await _make_profile(db_session, "public-watchuser")
2759 response = await client.get("/api/v1/users/public-watchuser/watched")
2760 assert response.status_code == 200
2761 assert response.status_code != 401
2762
2763
2764 @pytest.mark.anyio
2765 async def test_profile_watched_repos_ordered_newest_first(
2766 client: AsyncClient,
2767 db_session: AsyncSession,
2768 ) -> None:
2769 """GET /api/v1/users/{username}/watched returns watches newest first."""
2770 import datetime as dt
2771 from datetime import timezone
2772
2773 await _make_profile(db_session, "multiwatchuser")
2774
2775 repo_a = MusehubRepo(
2776 name="song-alpha", owner="band-a", slug="song-alpha",
2777 visibility="public", owner_user_id="band-a-id",
2778 )
2779 repo_b = MusehubRepo(
2780 name="song-beta", owner="band-b", slug="song-beta",
2781 visibility="public", owner_user_id="band-b-id",
2782 )
2783 db_session.add_all([repo_a, repo_b])
2784 await db_session.commit()
2785 await db_session.refresh(repo_a)
2786 await db_session.refresh(repo_b)
2787
2788 watch_a = MusehubWatch(
2789 repo_id=repo_a.repo_id,
2790 user_id=_TEST_USER_ID,
2791 created_at=dt.datetime(2024, 1, 1, tzinfo=timezone.utc),
2792 )
2793 watch_b = MusehubWatch(
2794 repo_id=repo_b.repo_id,
2795 user_id=_TEST_USER_ID,
2796 created_at=dt.datetime(2024, 6, 1, tzinfo=timezone.utc),
2797 )
2798 db_session.add_all([watch_a, watch_b])
2799 await db_session.commit()
2800
2801 response = await client.get("/api/v1/users/multiwatchuser/watched")
2802 assert response.status_code == 200
2803 data = response.json()
2804 assert data["total"] == 2
2805 # newest first: watch_b (June) before watch_a (January)
2806 assert data["watched"][0]["repo"]["slug"] == "song-beta"
2807 assert data["watched"][1]["repo"]["slug"] == "song-alpha"
2808
2809
2810 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
2811 @pytest.mark.anyio
2812 async def test_profile_page_has_watched_section_js(
2813 client: AsyncClient,
2814 db_session: AsyncSession,
2815 ) -> None:
2816 """Profile HTML page includes the watched repos JS (loadWatchedRepos, watched-section)."""
2817 await _make_profile(db_session, "jswatchuser")
2818 response = await client.get("/users/jswatchuser")
2819 assert response.status_code == 200
2820 body = response.text
2821 assert "loadWatchedRepos" in body
2822 assert "watched-section" in body
2823 assert "API_WATCHED" in body
2824
2825
2826 @pytest.mark.anyio
2827 async def test_timeline_page_renders(
2828 client: AsyncClient,
2829 db_session: AsyncSession,
2830 ) -> None:
2831 """GET /{repo_id}/timeline returns 200 HTML without requiring a JWT."""
2832 repo_id = await _make_repo(db_session)
2833 response = await client.get("/testuser/test-beats/timeline")
2834 assert response.status_code == 200
2835 assert "text/html" in response.headers["content-type"]
2836 body = response.text
2837 assert "MuseHub" in body
2838 assert "timeline" in body.lower()
2839 assert repo_id[:8] in body
2840
2841
2842 @pytest.mark.anyio
2843 async def test_timeline_page_no_auth_required(
2844 client: AsyncClient,
2845 db_session: AsyncSession,
2846 ) -> None:
2847 """Timeline UI route must be accessible without an Authorization header."""
2848 repo_id = await _make_repo(db_session)
2849 response = await client.get("/testuser/test-beats/timeline")
2850 assert response.status_code != 401
2851 assert response.status_code == 200
2852
2853
2854 @pytest.mark.anyio
2855 async def test_timeline_page_contains_layer_controls(
2856 client: AsyncClient,
2857 db_session: AsyncSession,
2858 ) -> None:
2859 """Timeline page embeds toggleable layer controls for all four layers."""
2860 repo_id = await _make_repo(db_session)
2861 response = await client.get("/testuser/test-beats/timeline")
2862 assert response.status_code == 200
2863 body = response.text
2864 assert "Commits" in body
2865 assert "Emotion" in body
2866 assert "Sections" in body
2867 assert "Tracks" in body
2868
2869
2870 @pytest.mark.anyio
2871 async def test_timeline_page_contains_zoom_controls(
2872 client: AsyncClient,
2873 db_session: AsyncSession,
2874 ) -> None:
2875 """Timeline page embeds day/week/month/all zoom buttons."""
2876 repo_id = await _make_repo(db_session)
2877 response = await client.get("/testuser/test-beats/timeline")
2878 assert response.status_code == 200
2879 body = response.text
2880 assert "Day" in body
2881 assert "Week" in body
2882 assert "Month" in body
2883 assert "All" in body
2884
2885
2886 @pytest.mark.anyio
2887 async def test_timeline_page_includes_token_form(
2888 client: AsyncClient,
2889 db_session: AsyncSession,
2890 ) -> None:
2891 """Timeline page includes the JWT token form and app.js via base.html."""
2892 repo_id = await _make_repo(db_session)
2893 response = await client.get("/testuser/test-beats/timeline")
2894 assert response.status_code == 200
2895 body = response.text
2896 assert "static/app.js" in body
2897 assert "token-form" in body
2898
2899
2900 @pytest.mark.anyio
2901 async def test_timeline_page_contains_overlay_toggles(
2902 client: AsyncClient,
2903 db_session: AsyncSession,
2904 ) -> None:
2905 """Timeline page must include Sessions, PRs, and Releases layer toggle checkboxes.
2906
2907 Regression test — before this fix the timeline had no
2908 overlay markers for repo lifecycle events (sessions, PR merges, releases).
2909 """
2910 await _make_repo(db_session)
2911 response = await client.get("/testuser/test-beats/timeline")
2912 assert response.status_code == 200
2913 body = response.text
2914 # All three new overlay toggle labels must be present.
2915 assert "Sessions" in body
2916 assert "PRs" in body
2917 assert "Releases" in body
2918
2919
2920 @pytest.mark.anyio
2921 async def test_timeline_page_overlay_js_variables(
2922 client: AsyncClient,
2923 db_session: AsyncSession,
2924 ) -> None:
2925 """Timeline page script must initialise sessions, mergedPRs, and releases arrays.
2926
2927 Verifies that the overlay data arrays and the layer toggle state are wired
2928 up in the page's inline script so the renderer can draw markers.
2929 """
2930 await _make_repo(db_session)
2931 response = await client.get("/testuser/test-beats/timeline")
2932 assert response.status_code == 200
2933 body = response.text
2934 # State variables for overlay data must be declared.
2935 assert "let sessions" in body
2936 assert "let mergedPRs" in body
2937 assert "let releases" in body
2938 # Layer toggle state must include the three new keys.
2939 assert "sessions: true" in body
2940 assert "prs: true" in body
2941 assert "releases: true" in body
2942
2943
2944 @pytest.mark.anyio
2945 async def test_timeline_page_overlay_fetch_calls(
2946 client: AsyncClient,
2947 db_session: AsyncSession,
2948 ) -> None:
2949 """Timeline page must issue API calls for sessions, merged PRs, and releases.
2950
2951 Checks that the inline script calls the correct API paths so the browser
2952 will fetch overlay data when the page loads.
2953 """
2954 await _make_repo(db_session)
2955 response = await client.get("/testuser/test-beats/timeline")
2956 assert response.status_code == 200
2957 body = response.text
2958 assert "/sessions" in body
2959 assert "state=merged" in body
2960 assert "/releases" in body
2961
2962
2963 @pytest.mark.anyio
2964 async def test_timeline_page_overlay_legend(
2965 client: AsyncClient,
2966 db_session: AsyncSession,
2967 ) -> None:
2968 """Timeline page legend must describe the three new overlay marker types."""
2969 await _make_repo(db_session)
2970 response = await client.get("/testuser/test-beats/timeline")
2971 assert response.status_code == 200
2972 body = response.text
2973 # Colour labels in the legend.
2974 assert "teal" in body.lower()
2975 assert "gold" in body.lower()
2976
2977
2978 @pytest.mark.anyio
2979 async def test_timeline_pr_markers_use_merged_at_for_positioning(
2980 client: AsyncClient,
2981 db_session: AsyncSession,
2982 ) -> None:
2983 """Timeline JS must use pr.mergedAt (falling back to pr.createdAt) for marker X position.
2984
2985 Regression test: previously the overlay always used
2986 createdAt, which positioned merge markers at PR *open* time instead of
2987 merge time, sometimes off by days or weeks.
2988 """
2989 await _make_repo(db_session)
2990 response = await client.get("/testuser/test-beats/timeline")
2991 assert response.status_code == 200
2992 body = response.text
2993 # The fix: JS should reference pr.mergedAt and fall back to pr.createdAt.
2994 assert "pr.mergedAt" in body
2995 assert "pr.createdAt" in body
2996 # The tooltip timestamp must also use mergedAt.
2997 assert "new Date(pr.mergedAt)" in body
2998
2999
3000 @pytest.mark.anyio
3001 async def test_pr_response_includes_merged_at_after_merge(
3002 client: AsyncClient,
3003 db_session: AsyncSession,
3004 auth_headers: dict[str, str],
3005 ) -> None:
3006 """PRResponse must expose merged_at set to the merge timestamp (not None) after merge.
3007
3008 Regression test: before this fix merged_at was absent from
3009 PRResponse, forcing the timeline to fall back to createdAt.
3010 """
3011 from datetime import datetime, timezone
3012
3013 from musehub.services import musehub_pull_requests, musehub_repository
3014
3015 repo_id = await _make_repo(db_session)
3016
3017 # Create two branches with commits so the merge can proceed.
3018 import uuid as _uuid
3019
3020 from musehub.db import musehub_models as dbm
3021
3022 commit_a_id = _uuid.uuid4().hex
3023 commit_main_id = _uuid.uuid4().hex
3024
3025 commit_a = dbm.MusehubCommit(
3026 commit_id=commit_a_id,
3027 repo_id=repo_id,
3028 branch="feat/test-merge",
3029 parent_ids=[],
3030 message="test commit on feature branch",
3031 author="tester",
3032 timestamp=datetime.now(timezone.utc),
3033 )
3034 branch_a = dbm.MusehubBranch(
3035 repo_id=repo_id, name="feat/test-merge", head_commit_id=commit_a_id
3036 )
3037 db_session.add(commit_a)
3038 db_session.add(branch_a)
3039
3040 commit_main = dbm.MusehubCommit(
3041 commit_id=commit_main_id,
3042 repo_id=repo_id,
3043 branch="main",
3044 parent_ids=[],
3045 message="initial commit on main",
3046 author="tester",
3047 timestamp=datetime.now(timezone.utc),
3048 )
3049 branch_main = dbm.MusehubBranch(
3050 repo_id=repo_id, name="main", head_commit_id=commit_main_id
3051 )
3052 db_session.add(commit_main)
3053 db_session.add(branch_main)
3054 await db_session.flush()
3055
3056 pr = await musehub_pull_requests.create_pr(
3057 db_session,
3058 repo_id=repo_id,
3059 title="Test merge PR",
3060 from_branch="feat/test-merge",
3061 to_branch="main",
3062 body="",
3063 author="tester",
3064 )
3065 await db_session.flush()
3066
3067 before_merge = datetime.now(timezone.utc)
3068 merged_pr = await musehub_pull_requests.merge_pr(
3069 db_session, repo_id, pr.pr_id, merge_strategy="merge_commit"
3070 )
3071 after_merge = datetime.now(timezone.utc)
3072
3073 assert merged_pr.merged_at is not None, "merged_at must be set after merge"
3074 # merged_at must be a timezone-aware datetime between before and after the merge call.
3075 merged_at = merged_pr.merged_at
3076 if merged_at.tzinfo is None:
3077 merged_at = merged_at.replace(tzinfo=timezone.utc)
3078 assert before_merge <= merged_at <= after_merge, (
3079 f"merged_at {merged_at} is outside the expected range [{before_merge}, {after_merge}]"
3080 )
3081 assert merged_pr.state == "merged"
3082
3083
3084 # ---------------------------------------------------------------------------
3085 # Embed player route tests
3086 # ---------------------------------------------------------------------------
3087
3088
3089 _UTC = timezone.utc
3090
3091
3092 @pytest.mark.anyio
3093 async def test_graph_page_contains_dag_js(
3094 client: AsyncClient,
3095 db_session: AsyncSession,
3096 ) -> None:
3097 """Graph page embeds the client-side DAG renderer JavaScript."""
3098 repo_id = await _make_repo(db_session)
3099 response = await client.get("/testuser/test-beats/graph")
3100 assert response.status_code == 200
3101 body = response.text
3102 assert "renderGraph" in body
3103 assert "dag-viewport" in body
3104 assert "dag-svg" in body
3105
3106
3107 @pytest.mark.anyio
3108 async def test_graph_page_contains_session_ring_js(
3109 client: AsyncClient,
3110 db_session: AsyncSession,
3111 ) -> None:
3112 """Graph page embeds session-marker and reaction-count JavaScript.
3113
3114 Regression guard: ensures the template contains the three
3115 new client-side components added by this feature — SESSION_RING_COLOR (the
3116 teal ring constant), buildSessionMap (commit→session index builder), and
3117 fetchReactions (batch reaction fetcher). A missing symbol means the graph
3118 will silently render with no session markers or reaction counts.
3119 """
3120 await _make_repo(db_session)
3121 response = await client.get("/testuser/test-beats/graph")
3122 assert response.status_code == 200
3123 body = response.text
3124 assert "SESSION_RING_COLOR" in body, "Teal session ring constant missing from graph page"
3125 assert "buildSessionMap" in body, "buildSessionMap function missing from graph page"
3126 assert "fetchReactions" in body, "fetchReactions function missing from graph page"
3127
3128
3129 @pytest.mark.anyio
3130 async def test_session_list_page_returns_200(
3131 client: AsyncClient,
3132 db_session: AsyncSession,
3133 ) -> None:
3134 """GET /{repo_id}/sessions returns 200 HTML without requiring a JWT."""
3135 repo_id = await _make_repo(db_session)
3136 response = await client.get("/testuser/test-beats/sessions")
3137 assert response.status_code == 200
3138 assert "text/html" in response.headers["content-type"]
3139 body = response.text
3140 assert "MuseHub" in body
3141 assert "Sessions" in body
3142 assert "static/app.js" in body
3143
3144
3145 @pytest.mark.anyio
3146 async def test_session_detail_renders(
3147 client: AsyncClient,
3148 db_session: AsyncSession,
3149 ) -> None:
3150 """GET /{owner}/{repo_slug}/sessions/{session_id} returns 200 HTML."""
3151 repo_id = await _make_repo(db_session)
3152 session_id = await _make_session(db_session, repo_id)
3153 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
3154 assert response.status_code == 200
3155 assert "text/html" in response.headers["content-type"]
3156 body = response.text
3157 assert "MuseHub" in body
3158 assert "Session" in body
3159 assert session_id[:8] in body
3160
3161
3162 @pytest.mark.anyio
3163 async def test_session_detail_participants(
3164 client: AsyncClient,
3165 db_session: AsyncSession,
3166 ) -> None:
3167 """Session detail page renders the Participants sidebar section."""
3168 repo_id = await _make_repo(db_session)
3169 session_id = await _make_session(db_session, repo_id, participants=["alice", "bob"])
3170 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
3171 assert response.status_code == 200
3172 body = response.text
3173 assert "Participants" in body
3174 assert "alice" in body
3175
3176
3177 @pytest.mark.anyio
3178 async def test_session_detail_commits(
3179 client: AsyncClient,
3180 db_session: AsyncSession,
3181 ) -> None:
3182 """Session detail page renders commit pills when commits are present."""
3183 repo_id = await _make_repo(db_session)
3184 session_id = await _make_session(
3185 db_session, repo_id, commits=["abc1234567890", "def9876543210"]
3186 )
3187 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
3188 assert response.status_code == 200
3189 body = response.text
3190 assert "Commits" in body
3191 assert "commit-pill" in body
3192
3193
3194 @pytest.mark.anyio
3195 async def test_session_detail_404_for_unknown_session(
3196 client: AsyncClient,
3197 db_session: AsyncSession,
3198 ) -> None:
3199 """Session detail route returns HTTP 404 for an unknown session ID.
3200
3201 The route is fully SSR — it performs a real DB lookup and returns 404
3202 rather than a JS shell that handles missing data client-side.
3203 """
3204 await _make_repo(db_session)
3205 response = await client.get("/testuser/test-beats/sessions/does-not-exist-1234")
3206 assert response.status_code == 404
3207
3208
3209 @pytest.mark.anyio
3210 async def test_session_detail_shows_intent(
3211 client: AsyncClient,
3212 db_session: AsyncSession,
3213 ) -> None:
3214 """Session detail page renders the intent field when present."""
3215 repo_id = await _make_repo(db_session)
3216 session_id = await _make_session(db_session, repo_id, intent="ambient soundscape")
3217 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
3218 assert response.status_code == 200
3219 assert "ambient soundscape" in response.text
3220
3221
3222 @pytest.mark.anyio
3223 async def test_session_detail_shows_location(
3224 client: AsyncClient,
3225 db_session: AsyncSession,
3226 ) -> None:
3227 """Session detail page renders the location field when present."""
3228 repo_id = await _make_repo(db_session)
3229 session_id = await _make_session(db_session, repo_id)
3230 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
3231 assert response.status_code == 200
3232 assert "Studio A" in response.text
3233
3234
3235 @pytest.mark.anyio
3236 async def test_session_detail_shows_meta_labels(
3237 client: AsyncClient,
3238 db_session: AsyncSession,
3239 ) -> None:
3240 """Session detail page renders the meta-label / meta-value row layout."""
3241 repo_id = await _make_repo(db_session)
3242 session_id = await _make_session(db_session, repo_id)
3243 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
3244 assert response.status_code == 200
3245 body = response.text
3246 assert "meta-label" in body
3247 assert "meta-value" in body
3248 assert "Started" in body
3249
3250
3251 @pytest.mark.anyio
3252 async def test_session_detail_active_shows_live_badge(
3253 client: AsyncClient,
3254 db_session: AsyncSession,
3255 ) -> None:
3256 """Session detail page shows a live badge for an active session."""
3257 repo_id = await _make_repo(db_session)
3258 session_id = await _make_session(db_session, repo_id, is_active=True)
3259 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
3260 assert response.status_code == 200
3261 body = response.text
3262 assert "live" in body
3263 assert "session-live-dot" in body
3264
3265
3266 @pytest.mark.anyio
3267 async def test_session_detail_ended_shows_badge(
3268 client: AsyncClient,
3269 db_session: AsyncSession,
3270 ) -> None:
3271 """Session detail page shows 'ended' badge for a completed session."""
3272 repo_id = await _make_repo(db_session)
3273 session_id = await _make_session(db_session, repo_id, is_active=False)
3274 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
3275 assert response.status_code == 200
3276 assert "ended" in response.text
3277
3278
3279 @pytest.mark.anyio
3280 async def test_session_detail_shows_notes(
3281 client: AsyncClient,
3282 db_session: AsyncSession,
3283 ) -> None:
3284 """Session detail page renders closing notes when present."""
3285 repo_id = await _make_repo(db_session)
3286 session_id = await _make_session(
3287 db_session, repo_id, notes="Great vibe, revisit the bridge section."
3288 )
3289 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
3290 assert response.status_code == 200
3291 assert "Great vibe, revisit the bridge section." in response.text
3292
3293
3294 async def _make_session(
3295 db_session: AsyncSession,
3296 repo_id: str,
3297 *,
3298 started_offset_seconds: int = 0,
3299 is_active: bool = False,
3300 intent: str = "jazz composition",
3301 participants: list[str] | None = None,
3302 commits: list[str] | None = None,
3303 notes: str = "",
3304 ) -> str:
3305 """Seed a MusehubSession and return its session_id."""
3306 start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
3307 from datetime import timedelta
3308
3309 started_at = start + timedelta(seconds=started_offset_seconds)
3310 ended_at = None if is_active else started_at + timedelta(hours=1)
3311 row = MusehubSession(
3312 repo_id=repo_id,
3313 started_at=started_at,
3314 ended_at=ended_at,
3315 participants=participants or ["producer-a"],
3316 commits=commits or [],
3317 notes=notes,
3318 intent=intent,
3319 location="Studio A",
3320 is_active=is_active,
3321 )
3322 db_session.add(row)
3323 await db_session.commit()
3324 await db_session.refresh(row)
3325 return str(row.session_id)
3326
3327
3328 @pytest.mark.anyio
3329 async def test_sessions_json_response(
3330 client: AsyncClient,
3331 db_session: AsyncSession,
3332 auth_headers: dict[str, str],
3333 ) -> None:
3334 """GET /api/v1/repos/{repo_id}/sessions returns session list with metadata."""
3335 repo_id = await _make_repo(db_session)
3336 session_id = await _make_session(db_session, repo_id, intent="jazz solo")
3337
3338 response = await client.get(
3339 f"/api/v1/repos/{repo_id}/sessions",
3340 headers=auth_headers,
3341 )
3342 assert response.status_code == 200
3343 data = response.json()
3344 assert "sessions" in data
3345 assert "total" in data
3346 assert data["total"] == 1
3347 sess = data["sessions"][0]
3348 assert sess["sessionId"] == session_id
3349 assert sess["intent"] == "jazz solo"
3350 assert sess["location"] == "Studio A"
3351 assert sess["isActive"] is False
3352 assert sess["durationSeconds"] == pytest.approx(3600.0)
3353
3354
3355 @pytest.mark.anyio
3356 async def test_sessions_newest_first(
3357 client: AsyncClient,
3358 db_session: AsyncSession,
3359 auth_headers: dict[str, str],
3360 ) -> None:
3361 """Sessions are returned newest-first (active sessions appear before ended sessions)."""
3362 repo_id = await _make_repo(db_session)
3363 # older ended session
3364 await _make_session(db_session, repo_id, started_offset_seconds=0, intent="older")
3365 # newer ended session
3366 await _make_session(db_session, repo_id, started_offset_seconds=3600, intent="newer")
3367 # active session (should surface first regardless of time)
3368 await _make_session(
3369 db_session, repo_id, started_offset_seconds=100, is_active=True, intent="live"
3370 )
3371
3372 response = await client.get(
3373 f"/api/v1/repos/{repo_id}/sessions",
3374 headers=auth_headers,
3375 )
3376 assert response.status_code == 200
3377 sessions = response.json()["sessions"]
3378 assert len(sessions) == 3
3379 # Active session must come first
3380 assert sessions[0]["isActive"] is True
3381 assert sessions[0]["intent"] == "live"
3382 # Then newest ended session
3383 assert sessions[1]["intent"] == "newer"
3384 assert sessions[2]["intent"] == "older"
3385
3386
3387 @pytest.mark.anyio
3388 async def test_sessions_empty_for_new_repo(
3389 client: AsyncClient,
3390 db_session: AsyncSession,
3391 auth_headers: dict[str, str],
3392 ) -> None:
3393 """GET /api/v1/repos/{repo_id}/sessions returns empty list for new repo."""
3394 repo_id = await _make_repo(db_session)
3395 response = await client.get(
3396 f"/api/v1/repos/{repo_id}/sessions",
3397 headers=auth_headers,
3398 )
3399 assert response.status_code == 200
3400 data = response.json()
3401 assert data["sessions"] == []
3402 assert data["total"] == 0
3403
3404
3405 @pytest.mark.anyio
3406 async def test_sessions_requires_auth(
3407 client: AsyncClient,
3408 db_session: AsyncSession,
3409 ) -> None:
3410 """GET /api/v1/repos/{repo_id}/sessions returns 401 without auth."""
3411 repo_id = await _make_repo(db_session)
3412 response = await client.get(f"/api/v1/repos/{repo_id}/sessions")
3413 assert response.status_code == 401
3414
3415
3416 @pytest.mark.anyio
3417 async def test_sessions_404_for_unknown_repo(
3418 client: AsyncClient,
3419 db_session: AsyncSession,
3420 auth_headers: dict[str, str],
3421 ) -> None:
3422 """GET /api/v1/repos/{unknown}/sessions returns 404."""
3423 response = await client.get(
3424 "/api/v1/repos/does-not-exist/sessions",
3425 headers=auth_headers,
3426 )
3427 assert response.status_code == 404
3428
3429
3430 @pytest.mark.anyio
3431 async def test_create_session_returns_201(
3432 client: AsyncClient,
3433 db_session: AsyncSession,
3434 auth_headers: dict[str, str],
3435 ) -> None:
3436 """POST /api/v1/repos/{repo_id}/sessions creates a session and returns 201."""
3437 repo_id = await _make_repo(db_session)
3438 payload = {
3439 "participants": ["producer-a", "collab-b"],
3440 "intent": "house beat experiment",
3441 "location": "Remote – Berlin",
3442 "isActive": True,
3443 }
3444 response = await client.post(
3445 f"/api/v1/repos/{repo_id}/sessions",
3446 json=payload,
3447 headers=auth_headers,
3448 )
3449 assert response.status_code == 201
3450 data = response.json()
3451 assert data["isActive"] is True
3452 assert data["intent"] == "house beat experiment"
3453 assert data["location"] == "Remote \u2013 Berlin"
3454 assert data["participants"] == ["producer-a", "collab-b"]
3455 assert "sessionId" in data
3456
3457
3458 @pytest.mark.anyio
3459 async def test_stop_session_marks_ended(
3460 client: AsyncClient,
3461 db_session: AsyncSession,
3462 auth_headers: dict[str, str],
3463 ) -> None:
3464 """POST /api/v1/repos/{repo_id}/sessions/{session_id}/stop closes a live session."""
3465 repo_id = await _make_repo(db_session)
3466 session_id = await _make_session(db_session, repo_id, is_active=True)
3467
3468 response = await client.post(
3469 f"/api/v1/repos/{repo_id}/sessions/{session_id}/stop",
3470 json={},
3471 headers=auth_headers,
3472 )
3473 assert response.status_code == 200
3474 data = response.json()
3475 assert data["isActive"] is False
3476 assert data["endedAt"] is not None
3477 assert data["durationSeconds"] is not None
3478
3479
3480 @pytest.mark.anyio
3481 async def test_active_session_has_null_duration(
3482 client: AsyncClient,
3483 db_session: AsyncSession,
3484 auth_headers: dict[str, str],
3485 ) -> None:
3486 """Active sessions must have durationSeconds=null (session still in progress)."""
3487 repo_id = await _make_repo(db_session)
3488 await _make_session(db_session, repo_id, is_active=True)
3489
3490 response = await client.get(
3491 f"/api/v1/repos/{repo_id}/sessions",
3492 headers=auth_headers,
3493 )
3494 assert response.status_code == 200
3495 sess = response.json()["sessions"][0]
3496 assert sess["isActive"] is True
3497 assert sess["durationSeconds"] is None
3498
3499
3500 @pytest.mark.anyio
3501 async def test_session_response_includes_commits_and_notes(
3502 client: AsyncClient,
3503 db_session: AsyncSession,
3504 auth_headers: dict[str, str],
3505 ) -> None:
3506 """SessionResponse includes commits list and notes field in the JSON payload."""
3507 repo_id = await _make_repo(db_session)
3508 commit_ids = ["abc123", "def456", "ghi789"]
3509 closing_notes = "Great session, nailed the groove."
3510 await _make_session(
3511 db_session,
3512 repo_id,
3513 intent="funk groove",
3514 commits=commit_ids,
3515 notes=closing_notes,
3516 )
3517
3518 response = await client.get(
3519 f"/api/v1/repos/{repo_id}/sessions",
3520 headers=auth_headers,
3521 )
3522 assert response.status_code == 200
3523 sess = response.json()["sessions"][0]
3524 assert sess["commits"] == commit_ids
3525 assert sess["notes"] == closing_notes
3526
3527
3528 @pytest.mark.anyio
3529 async def test_session_response_commits_field_present(
3530 client: AsyncClient,
3531 db_session: AsyncSession,
3532 auth_headers: dict[str, str],
3533 ) -> None:
3534 """Sessions API response includes the 'commits' field for each session.
3535
3536 Regression guard: the graph page uses the session commits
3537 list to build the session→commit index (buildSessionMap). If this field
3538 is absent or empty when commits exist, no session rings will appear on
3539 the DAG graph.
3540 """
3541 repo_id = await _make_repo(db_session)
3542 commit_ids = ["abc123def456abc123def456abc123de", "feedbeeffeedbeefdead000000000001"]
3543 row = MusehubSession(
3544 repo_id=repo_id,
3545 started_at=datetime(2025, 3, 1, 10, 0, 0, tzinfo=timezone.utc),
3546 ended_at=datetime(2025, 3, 1, 11, 0, 0, tzinfo=timezone.utc),
3547 participants=["artist-a"],
3548 intent="session with commits",
3549 location="Studio B",
3550 is_active=False,
3551 commits=commit_ids,
3552 )
3553 db_session.add(row)
3554 await db_session.commit()
3555 await db_session.refresh(row)
3556
3557 response = await client.get(
3558 f"/api/v1/repos/{repo_id}/sessions",
3559 headers=auth_headers,
3560 )
3561 assert response.status_code == 200
3562 sessions = response.json()["sessions"]
3563 assert len(sessions) == 1
3564 sess = sessions[0]
3565 assert "commits" in sess, "'commits' field missing from SessionResponse"
3566 assert sess["commits"] == commit_ids, "commits field does not match seeded commit IDs"
3567
3568
3569 @pytest.mark.anyio
3570 async def test_session_response_empty_commits_and_notes_defaults(
3571 client: AsyncClient,
3572 db_session: AsyncSession,
3573 auth_headers: dict[str, str],
3574 ) -> None:
3575 """SessionResponse defaults commits to [] and notes to '' when absent."""
3576 repo_id = await _make_repo(db_session)
3577 await _make_session(db_session, repo_id, intent="defaults check")
3578
3579 response = await client.get(
3580 f"/api/v1/repos/{repo_id}/sessions",
3581 headers=auth_headers,
3582 )
3583 assert response.status_code == 200
3584 sess = response.json()["sessions"][0]
3585 assert sess["commits"] == []
3586 assert sess["notes"] == ""
3587
3588
3589 @pytest.mark.anyio
3590 async def test_session_list_page_contains_avatar_markup(
3591 client: AsyncClient,
3592 db_session: AsyncSession,
3593 ) -> None:
3594 """Sessions list page renders participant chips when a session has participants."""
3595 repo_id = await _make_repo(db_session)
3596 await _make_session(db_session, repo_id, participants=["producer-a", "bassist"])
3597 response = await client.get("/testuser/test-beats/sessions")
3598 assert response.status_code == 200
3599 body = response.text
3600 assert "participant-chip" in body
3601 assert "producer-a" in body
3602
3603
3604 @pytest.mark.anyio
3605 async def test_session_list_page_contains_commit_pill_markup(
3606 client: AsyncClient,
3607 db_session: AsyncSession,
3608 ) -> None:
3609 """Sessions list page renders a commit count badge when a session has commits."""
3610 repo_id = await _make_repo(db_session)
3611 await _make_session(db_session, repo_id, commits=["abc123", "def456"])
3612 response = await client.get("/testuser/test-beats/sessions")
3613 assert response.status_code == 200
3614 body = response.text
3615 assert "🎵" in body
3616 assert "commit" in body
3617
3618
3619 @pytest.mark.anyio
3620 async def test_session_list_page_contains_live_indicator_markup(
3621 client: AsyncClient,
3622 db_session: AsyncSession,
3623 ) -> None:
3624 """Sessions list page renders the live dot badge for an active session."""
3625 repo_id = await _make_repo(db_session)
3626 await _make_session(db_session, repo_id, is_active=True)
3627 response = await client.get("/testuser/test-beats/sessions")
3628 assert response.status_code == 200
3629 body = response.text
3630 assert "session-live-dot" in body
3631 assert "live" in body
3632
3633
3634 @pytest.mark.anyio
3635 async def test_session_list_page_contains_notes_preview_markup(
3636 client: AsyncClient,
3637 db_session: AsyncSession,
3638 ) -> None:
3639 """Sessions list page renders the session notes when a session has notes."""
3640 repo_id = await _make_repo(db_session)
3641 await _make_session(db_session, repo_id, notes="Recorded the main piano riff")
3642 response = await client.get("/testuser/test-beats/sessions")
3643 assert response.status_code == 200
3644 body = response.text
3645 assert "session-notes" in body
3646 assert "Recorded the main piano riff" in body
3647
3648
3649 @pytest.mark.anyio
3650 async def test_session_list_page_contains_location_tag_markup(
3651 client: AsyncClient,
3652 db_session: AsyncSession,
3653 ) -> None:
3654 """Sessions list page renders location icon when a session has a location set."""
3655 repo_id = await _make_repo(db_session)
3656 await _make_session(db_session, repo_id) # _make_session sets location="Studio A"
3657 response = await client.get("/testuser/test-beats/sessions")
3658 assert response.status_code == 200
3659 body = response.text
3660 assert "session-row" in body
3661 assert "Studio A" in body
3662
3663
3664 async def test_contour_page_renders(
3665 client: AsyncClient,
3666 db_session: AsyncSession,
3667 ) -> None:
3668 """GET /{repo_id}/analysis/{ref}/contour returns 200 HTML."""
3669 repo_id = await _make_repo(db_session)
3670 ref = "abc1234567890abcdef"
3671 response = await client.get(f"/testuser/test-beats/analysis/{ref}/contour")
3672 assert response.status_code == 200
3673 assert "text/html" in response.headers["content-type"]
3674
3675
3676 @pytest.mark.anyio
3677 async def test_contour_page_no_auth_required(
3678 client: AsyncClient,
3679 db_session: AsyncSession,
3680 ) -> None:
3681 """Contour analysis page must be accessible without a JWT (HTML shell handles auth)."""
3682 repo_id = await _make_repo(db_session)
3683 ref = "deadbeef1234"
3684 response = await client.get(f"/testuser/test-beats/analysis/{ref}/contour")
3685 assert response.status_code != 401
3686 assert response.status_code == 200
3687
3688
3689 @pytest.mark.anyio
3690 async def test_contour_page_contains_graph_ui(
3691 client: AsyncClient,
3692 db_session: AsyncSession,
3693 ) -> None:
3694 """Contour page SSR: must contain pitch-curve polyline, shape summary, and direction data."""
3695 repo_id = await _make_repo(db_session)
3696 ref = "cafebabe12345678"
3697 response = await client.get(f"/testuser/test-beats/analysis/{ref}/contour")
3698 assert response.status_code == 200
3699 body = response.text
3700 assert "Melodic Contour" in body
3701 assert "<polyline" in body or "PITCH CURVE" in body
3702 assert "Shape" in body
3703 assert "Overall Direction" in body
3704 assert repo_id in body
3705
3706
3707 @pytest.mark.anyio
3708 async def test_contour_json_response(
3709 client: AsyncClient,
3710 auth_headers: dict[str, str],
3711 db_session: AsyncSession,
3712 ) -> None:
3713 """GET /api/v1/repos/{repo_id}/analysis/{ref}/contour returns ContourData.
3714
3715 Verifies that the JSON response includes shape classification labels and
3716 the pitch_curve array that the contour page visualises.
3717 """
3718 resp = await client.post(
3719 "/api/v1/repos",
3720 json={"name": "contour-test-repo", "owner": "testuser", "visibility": "private"},
3721 headers=auth_headers,
3722 )
3723 assert resp.status_code == 201
3724 repo_id = resp.json()["repoId"]
3725
3726 resp = await client.get(
3727 f"/api/v1/repos/{repo_id}/analysis/main/contour",
3728 headers=auth_headers,
3729 )
3730 assert resp.status_code == 200
3731 body = resp.json()
3732 assert body["dimension"] == "contour"
3733 assert body["ref"] == "main"
3734 data = body["data"]
3735 assert "shape" in data
3736 assert "pitchCurve" in data
3737 assert "overallDirection" in data
3738 assert "directionChanges" in data
3739 assert len(data["pitchCurve"]) > 0
3740 assert data["shape"] in ("arch", "ascending", "descending", "flat", "wave")
3741
3742
3743 @pytest.mark.anyio
3744 async def test_tempo_page_renders(
3745 client: AsyncClient,
3746 db_session: AsyncSession,
3747 ) -> None:
3748 """GET /{repo_id}/analysis/{ref}/tempo returns 200 HTML."""
3749 repo_id = await _make_repo(db_session)
3750 ref = "abc1234567890abcdef"
3751 response = await client.get(f"/testuser/test-beats/analysis/{ref}/tempo")
3752 assert response.status_code == 200
3753 assert "text/html" in response.headers["content-type"]
3754
3755
3756 @pytest.mark.anyio
3757 async def test_tempo_page_no_auth_required(
3758 client: AsyncClient,
3759 db_session: AsyncSession,
3760 ) -> None:
3761 """Tempo analysis page must be accessible without a JWT (HTML shell handles auth)."""
3762 repo_id = await _make_repo(db_session)
3763 ref = "deadbeef5678"
3764 response = await client.get(f"/testuser/test-beats/analysis/{ref}/tempo")
3765 assert response.status_code != 401
3766 assert response.status_code == 200
3767
3768
3769 @pytest.mark.anyio
3770 async def test_tempo_page_contains_bpm_ui(
3771 client: AsyncClient,
3772 db_session: AsyncSession,
3773 ) -> None:
3774 """Tempo page must contain BPM display, stability bar, and tempo-change timeline."""
3775 repo_id = await _make_repo(db_session)
3776 ref = "feedface5678"
3777 response = await client.get(f"/testuser/test-beats/analysis/{ref}/tempo")
3778 assert response.status_code == 200
3779 body = response.text
3780 assert "Tempo Analysis" in body
3781 assert "BPM" in body
3782 assert "Stability" in body
3783 assert "tempoChangeSvg" in body or "tempoChanges" in body or "Tempo Changes" in body
3784 assert repo_id in body
3785
3786
3787 @pytest.mark.anyio
3788 async def test_tempo_json_response(
3789 client: AsyncClient,
3790 auth_headers: dict[str, str],
3791 db_session: AsyncSession,
3792 ) -> None:
3793 """GET /api/v1/repos/{repo_id}/analysis/{ref}/tempo returns TempoData.
3794
3795 Verifies that the JSON response includes BPM, stability, time feel, and
3796 tempo_changes history that the tempo page visualises.
3797 """
3798 resp = await client.post(
3799 "/api/v1/repos",
3800 json={"name": "tempo-test-repo", "owner": "testuser", "visibility": "private"},
3801 headers=auth_headers,
3802 )
3803 assert resp.status_code == 201
3804 repo_id = resp.json()["repoId"]
3805
3806 resp = await client.get(
3807 f"/api/v1/repos/{repo_id}/analysis/main/tempo",
3808 headers=auth_headers,
3809 )
3810 assert resp.status_code == 200
3811 body = resp.json()
3812 assert body["dimension"] == "tempo"
3813 assert body["ref"] == "main"
3814 data = body["data"]
3815 assert "bpm" in data
3816 assert "stability" in data
3817 assert "timeFeel" in data
3818 assert "tempoChanges" in data
3819 assert data["bpm"] > 0
3820 assert 0.0 <= data["stability"] <= 1.0
3821 assert isinstance(data["tempoChanges"], list)
3822
3823
3824 # ---------------------------------------------------------------------------
3825 # Form and structure page tests
3826 # ---------------------------------------------------------------------------
3827
3828
3829 @pytest.mark.anyio
3830 async def test_form_structure_page_renders(
3831 client: AsyncClient,
3832 db_session: AsyncSession,
3833 ) -> None:
3834 """GET /{repo_id}/form-structure/{ref} returns 200 HTML without auth."""
3835 repo_id = await _make_repo(db_session)
3836 ref = "abc1234567890abcdef"
3837 response = await client.get(f"/{repo_id}/form-structure/{ref}")
3838 assert response.status_code == 200
3839 assert "text/html" in response.headers["content-type"]
3840 body = response.text
3841 assert "MuseHub" in body
3842 assert "Form" in body
3843
3844
3845 @pytest.mark.anyio
3846 async def test_form_structure_page_no_auth_required(
3847 client: AsyncClient,
3848 db_session: AsyncSession,
3849 ) -> None:
3850 """Form-structure UI page must be accessible without an Authorization header."""
3851 repo_id = await _make_repo(db_session)
3852 ref = "deadbeef1234"
3853 response = await client.get(f"/{repo_id}/form-structure/{ref}")
3854 assert response.status_code != 401
3855 assert response.status_code == 200
3856
3857
3858 @pytest.mark.anyio
3859 async def test_form_structure_page_contains_section_map(
3860 client: AsyncClient,
3861 db_session: AsyncSession,
3862 ) -> None:
3863 """Form-structure page embeds section map SVG rendering logic."""
3864 repo_id = await _make_repo(db_session)
3865 ref = "cafebabe1234"
3866 response = await client.get(f"/{repo_id}/form-structure/{ref}")
3867 assert response.status_code == 200
3868 body = response.text
3869 assert "Section Map" in body
3870 assert "renderSectionMap" in body
3871 assert "sectionMap" in body
3872
3873
3874 @pytest.mark.anyio
3875 async def test_form_structure_page_contains_repetition_panel(
3876 client: AsyncClient,
3877 db_session: AsyncSession,
3878 ) -> None:
3879 """Form-structure page embeds repetition structure panel."""
3880 repo_id = await _make_repo(db_session)
3881 ref = "feedface0123"
3882 response = await client.get(f"/{repo_id}/form-structure/{ref}")
3883 assert response.status_code == 200
3884 body = response.text
3885 assert "Repetition" in body
3886 assert "renderRepetition" in body
3887
3888
3889 @pytest.mark.anyio
3890 async def test_form_structure_page_contains_heatmap(
3891 client: AsyncClient,
3892 db_session: AsyncSession,
3893 ) -> None:
3894 """Form-structure page embeds section comparison heatmap renderer."""
3895 repo_id = await _make_repo(db_session)
3896 ref = "deadcafe5678"
3897 response = await client.get(f"/{repo_id}/form-structure/{ref}")
3898 assert response.status_code == 200
3899 body = response.text
3900 assert "Section Comparison" in body
3901 assert "renderHeatmap" in body
3902 assert "sectionComparison" in body
3903
3904
3905 @pytest.mark.anyio
3906 async def test_form_structure_page_includes_token_form(
3907 client: AsyncClient,
3908 db_session: AsyncSession,
3909 ) -> None:
3910 """Form-structure page includes the JWT token form and app.js via base.html."""
3911 repo_id = await _make_repo(db_session)
3912 ref = "babe1234abcd"
3913 response = await client.get(f"/{repo_id}/form-structure/{ref}")
3914 assert response.status_code == 200
3915 body = response.text
3916 assert "app.js" in body
3917 assert "token-form" in body
3918
3919
3920 @pytest.mark.anyio
3921 async def test_form_structure_json_response(
3922 client: AsyncClient,
3923 db_session: AsyncSession,
3924 auth_headers: dict[str, str],
3925 ) -> None:
3926 """GET /api/v1/repos/{repo_id}/form-structure/{ref} returns JSON with required fields."""
3927 repo_id = await _make_repo(db_session)
3928 ref = "abc1234567890abcdef"
3929 response = await client.get(
3930 f"/api/v1/repos/{repo_id}/form-structure/{ref}",
3931 headers=auth_headers,
3932 )
3933 assert response.status_code == 200
3934 body = response.json()
3935 assert "repoId" in body
3936 assert "ref" in body
3937 assert "formLabel" in body
3938 assert "timeSignature" in body
3939 assert "beatsPerBar" in body
3940 assert "totalBars" in body
3941 assert "sectionMap" in body
3942 assert "repetitionStructure" in body
3943 assert "sectionComparison" in body
3944 assert body["repoId"] == repo_id
3945 assert body["ref"] == ref
3946
3947
3948 @pytest.mark.anyio
3949 async def test_form_structure_json_section_map_fields(
3950 client: AsyncClient,
3951 db_session: AsyncSession,
3952 auth_headers: dict[str, str],
3953 ) -> None:
3954 """Each sectionMap entry has label, startBar, endBar, barCount, and colorHint."""
3955 repo_id = await _make_repo(db_session)
3956 ref = "abc1234567890abcdef"
3957 response = await client.get(
3958 f"/api/v1/repos/{repo_id}/form-structure/{ref}",
3959 headers=auth_headers,
3960 )
3961 assert response.status_code == 200
3962 body = response.json()
3963 sections = body["sectionMap"]
3964 assert len(sections) > 0
3965 for sec in sections:
3966 assert "label" in sec
3967 assert "function" in sec
3968 assert "startBar" in sec
3969 assert "endBar" in sec
3970 assert "barCount" in sec
3971 assert "colorHint" in sec
3972 assert sec["startBar"] >= 1
3973 assert sec["endBar"] >= sec["startBar"]
3974 assert sec["barCount"] >= 1
3975
3976
3977 @pytest.mark.anyio
3978 async def test_form_structure_json_heatmap_is_symmetric(
3979 client: AsyncClient,
3980 db_session: AsyncSession,
3981 auth_headers: dict[str, str],
3982 ) -> None:
3983 """Section comparison heatmap matrix must be square and symmetric with diagonal 1.0."""
3984 repo_id = await _make_repo(db_session)
3985 ref = "abc1234567890abcdef"
3986 response = await client.get(
3987 f"/api/v1/repos/{repo_id}/form-structure/{ref}",
3988 headers=auth_headers,
3989 )
3990 assert response.status_code == 200
3991 body = response.json()
3992 heatmap = body["sectionComparison"]
3993 labels = heatmap["labels"]
3994 matrix = heatmap["matrix"]
3995 n = len(labels)
3996 assert len(matrix) == n
3997 for i in range(n):
3998 assert len(matrix[i]) == n
3999 assert matrix[i][i] == 1.0
4000 for i in range(n):
4001 for j in range(n):
4002 assert 0.0 <= matrix[i][j] <= 1.0
4003
4004
4005 @pytest.mark.anyio
4006 async def test_form_structure_json_404_unknown_repo(
4007 client: AsyncClient,
4008 db_session: AsyncSession,
4009 auth_headers: dict[str, str],
4010 ) -> None:
4011 """GET /api/v1/repos/{unknown}/form-structure/{ref} returns 404."""
4012 response = await client.get(
4013 "/api/v1/repos/does-not-exist/form-structure/abc123",
4014 headers=auth_headers,
4015 )
4016 assert response.status_code == 404
4017
4018
4019 @pytest.mark.anyio
4020 async def test_form_structure_json_requires_auth(
4021 client: AsyncClient,
4022 db_session: AsyncSession,
4023 ) -> None:
4024 """GET /api/v1/repos/{repo_id}/form-structure/{ref} returns 401 without auth."""
4025 repo_id = await _make_repo(db_session)
4026 response = await client.get(
4027 f"/api/v1/repos/{repo_id}/form-structure/abc123",
4028 )
4029 assert response.status_code == 401
4030
4031
4032 # ---------------------------------------------------------------------------
4033 # Emotion map page tests (migrated to owner/slug routing)
4034 # ---------------------------------------------------------------------------
4035
4036 _EMOTION_REF = "deadbeef12345678"
4037
4038
4039 @pytest.mark.anyio
4040 async def test_emotion_page_renders(
4041 client: AsyncClient,
4042 db_session: AsyncSession,
4043 ) -> None:
4044 """GET /{owner}/{repo_slug}/analysis/{ref}/emotion returns 200 HTML without auth."""
4045 await _make_repo(db_session)
4046 response = await client.get(f"/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
4047 assert response.status_code == 200
4048 assert "text/html" in response.headers["content-type"]
4049 body = response.text
4050 assert "MuseHub" in body
4051 assert "Emotion" in body
4052
4053
4054 @pytest.mark.anyio
4055 async def test_emotion_page_no_auth_required(
4056 client: AsyncClient,
4057 db_session: AsyncSession,
4058 ) -> None:
4059 """Emotion UI page must be accessible without an Authorization header (HTML shell)."""
4060 await _make_repo(db_session)
4061 response = await client.get(f"/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
4062 assert response.status_code != 401
4063 assert response.status_code == 200
4064
4065
4066 @pytest.mark.anyio
4067 async def test_emotion_page_includes_charts(
4068 client: AsyncClient,
4069 db_session: AsyncSession,
4070 ) -> None:
4071 """Emotion page SSR: must contain SVG scatter plot and axis dimension labels."""
4072 await _make_repo(db_session)
4073 response = await client.get(f"/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
4074 assert response.status_code == 200
4075 body = response.text
4076 assert "<circle" in body or "<svg" in body
4077 assert "Valence" in body
4078 assert "Tension" in body
4079 assert "Energy" in body
4080
4081
4082 @pytest.mark.anyio
4083 async def test_emotion_page_includes_filters(
4084 client: AsyncClient,
4085 db_session: AsyncSession,
4086 ) -> None:
4087 """Emotion page SSR: must contain summary vector bars and trajectory section."""
4088 await _make_repo(db_session)
4089 response = await client.get(f"/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
4090 assert response.status_code == 200
4091 body = response.text
4092 assert "SUMMARY VECTOR" in body
4093 assert "TRAJECTORY" in body
4094
4095
4096 @pytest.mark.anyio
4097 async def test_emotion_json_response(
4098 client: AsyncClient,
4099 db_session: AsyncSession,
4100 auth_headers: dict[str, str],
4101 ) -> None:
4102 """GET /api/v1/repos/{repo_id}/analysis/{ref}/emotion-map returns required fields."""
4103 repo_id = await _make_repo(db_session)
4104 response = await client.get(
4105 f"/api/v1/repos/{repo_id}/analysis/{_EMOTION_REF}/emotion-map",
4106 headers=auth_headers,
4107 )
4108 assert response.status_code == 200
4109 body = response.json()
4110 assert body["repoId"] == repo_id
4111 assert body["ref"] == _EMOTION_REF
4112 assert "computedAt" in body
4113 assert "summaryVector" in body
4114 sv = body["summaryVector"]
4115 for axis in ("energy", "valence", "tension", "darkness"):
4116 assert axis in sv
4117 assert 0.0 <= sv[axis] <= 1.0
4118 assert "evolution" in body
4119 assert isinstance(body["evolution"], list)
4120 assert len(body["evolution"]) > 0
4121 assert "narrative" in body
4122 assert len(body["narrative"]) > 0
4123 assert "source" in body
4124
4125
4126 @pytest.mark.anyio
4127 async def test_emotion_trajectory(
4128 client: AsyncClient,
4129 db_session: AsyncSession,
4130 auth_headers: dict[str, str],
4131 ) -> None:
4132 """Cross-commit trajectory must be a list of commit snapshots with emotion vectors."""
4133 repo_id = await _make_repo(db_session)
4134 response = await client.get(
4135 f"/api/v1/repos/{repo_id}/analysis/{_EMOTION_REF}/emotion-map",
4136 headers=auth_headers,
4137 )
4138 assert response.status_code == 200
4139 trajectory = response.json()["trajectory"]
4140 assert isinstance(trajectory, list)
4141 assert len(trajectory) >= 2
4142 for snapshot in trajectory:
4143 assert "commitId" in snapshot
4144 assert "message" in snapshot
4145 assert "primaryEmotion" in snapshot
4146 vector = snapshot["vector"]
4147 for axis in ("energy", "valence", "tension", "darkness"):
4148 assert axis in vector
4149 assert 0.0 <= vector[axis] <= 1.0
4150
4151
4152 @pytest.mark.anyio
4153 async def test_emotion_drift_distances(
4154 client: AsyncClient,
4155 db_session: AsyncSession,
4156 auth_headers: dict[str, str],
4157 ) -> None:
4158 """Drift list must have exactly len(trajectory) - 1 entries."""
4159 repo_id = await _make_repo(db_session)
4160 response = await client.get(
4161 f"/api/v1/repos/{repo_id}/analysis/{_EMOTION_REF}/emotion-map",
4162 headers=auth_headers,
4163 )
4164 assert response.status_code == 200
4165 body = response.json()
4166 trajectory = body["trajectory"]
4167 drift = body["drift"]
4168 assert isinstance(drift, list)
4169 assert len(drift) == len(trajectory) - 1
4170 for entry in drift:
4171 assert "fromCommit" in entry
4172 assert "toCommit" in entry
4173 assert "drift" in entry
4174 assert entry["drift"] >= 0.0
4175 assert "dominantChange" in entry
4176 assert entry["dominantChange"] in ("energy", "valence", "tension", "darkness")
4177
4178
4179 # ---------------------------------------------------------------------------
4180 # owner/slug navigation link correctness (regression for PR #282)
4181 # ---------------------------------------------------------------------------
4182
4183
4184 @pytest.mark.anyio
4185 async def test_ui_nav_links_use_owner_slug_not_uuid_repo_page(
4186 client: AsyncClient,
4187 db_session: AsyncSession,
4188 ) -> None:
4189 """Repo page must inject owner/slug base URL, not the internal UUID.
4190
4191 Before the fix, every handler except repo_page used ``const base =
4192 '/' + repoId``. That produced UUID-based hrefs that 404 under
4193 the new /{owner}/{repo_slug} routing. This test guards the regression.
4194 """
4195 await _make_repo(db_session)
4196 response = await client.get("/testuser/test-beats")
4197 assert response.status_code == 200
4198 body = response.text
4199 # JS base variable must use owner/slug, not UUID concatenation
4200 assert '"/testuser/test-beats"' in body
4201 # UUID-concatenation pattern must NOT appear
4202 assert "'/' + repoId" not in body
4203
4204
4205 @pytest.mark.anyio
4206 async def test_ui_nav_links_use_owner_slug_not_uuid_commit_page(
4207 client: AsyncClient,
4208 db_session: AsyncSession,
4209 ) -> None:
4210 """Commit page back-to-repo link must use owner/slug, not internal UUID."""
4211 from datetime import datetime, timezone
4212
4213 repo_id = await _make_repo(db_session)
4214 commit_id = "abc1234567890123456789012345678901234567"
4215 commit = MusehubCommit(
4216 commit_id=commit_id,
4217 repo_id=repo_id,
4218 branch="main",
4219 parent_ids=[],
4220 message="Test commit",
4221 author="testuser",
4222 timestamp=datetime.now(tz=timezone.utc),
4223 )
4224 db_session.add(commit)
4225 await db_session.commit()
4226
4227 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
4228 assert response.status_code == 200
4229 body = response.text
4230 assert "/testuser/test-beats" in body
4231 assert "'/' + repoId" not in body
4232
4233
4234 @pytest.mark.anyio
4235 async def test_ui_nav_links_use_owner_slug_not_uuid_graph_page(
4236 client: AsyncClient,
4237 db_session: AsyncSession,
4238 ) -> None:
4239 """Graph page back-to-repo link must use owner/slug, not internal UUID."""
4240 await _make_repo(db_session)
4241 response = await client.get("/testuser/test-beats/graph")
4242 assert response.status_code == 200
4243 body = response.text
4244 assert '"/testuser/test-beats"' in body
4245 assert "'/' + repoId" not in body
4246
4247
4248 @pytest.mark.anyio
4249 async def test_ui_nav_links_use_owner_slug_not_uuid_pr_list_page(
4250 client: AsyncClient,
4251 db_session: AsyncSession,
4252 ) -> None:
4253 """PR list page navigation must use owner/slug, not internal UUID."""
4254 await _make_repo(db_session)
4255 response = await client.get("/testuser/test-beats/pulls")
4256 assert response.status_code == 200
4257 body = response.text
4258 assert '"/testuser/test-beats"' in body
4259 assert "'/' + repoId" not in body
4260
4261
4262 @pytest.mark.anyio
4263 async def test_ui_nav_links_use_owner_slug_not_uuid_releases_page(
4264 client: AsyncClient,
4265 db_session: AsyncSession,
4266 ) -> None:
4267 """Releases page navigation must use owner/slug, not internal UUID."""
4268 await _make_repo(db_session)
4269 response = await client.get("/testuser/test-beats/releases")
4270 assert response.status_code == 200
4271 body = response.text
4272 assert '"/testuser/test-beats"' in body
4273 assert "'/' + repoId" not in body
4274
4275
4276 @pytest.mark.anyio
4277 async def test_ui_unknown_owner_slug_returns_404(
4278 client: AsyncClient,
4279 db_session: AsyncSession,
4280 ) -> None:
4281 """GET /{unknown-owner}/{unknown-slug} must return 404."""
4282 response = await client.get("/nobody/nonexistent-repo")
4283 assert response.status_code == 404
4284
4285
4286 # ---------------------------------------------------------------------------
4287 # Issue #199 — Design System Tests
4288 # ---------------------------------------------------------------------------
4289
4290
4291 @pytest.mark.anyio
4292 async def test_design_tokens_css_served(client: AsyncClient) -> None:
4293 """GET /static/tokens.css must return 200 with CSS content-type.
4294
4295 Verifies the design token file is reachable at its canonical static path.
4296 If this fails, every MuseHub page will render unstyled because the CSS
4297 custom properties (--bg-base, --color-accent, etc.) will be missing.
4298 """
4299 response = await client.get("/static/tokens.css")
4300 assert response.status_code == 200
4301 assert "text/css" in response.headers.get("content-type", "")
4302 body = response.text
4303 assert "--bg-base" in body
4304 assert "--color-accent" in body
4305 assert "--dim-harmonic" in body
4306
4307
4308 @pytest.mark.anyio
4309 async def test_components_css_served(client: AsyncClient) -> None:
4310 """GET /static/components.css must return 200 with CSS content.
4311
4312 Verifies the component class file is reachable. These classes (.card,
4313 .badge, .btn, etc.) are used on every MuseHub page.
4314 """
4315 response = await client.get("/static/components.css")
4316 assert response.status_code == 200
4317 assert "text/css" in response.headers.get("content-type", "")
4318 body = response.text
4319 assert ".badge" in body
4320 assert ".btn" in body
4321 assert ".card" in body
4322
4323
4324 @pytest.mark.anyio
4325 async def test_layout_css_served(client: AsyncClient) -> None:
4326 """GET /static/layout.css must return 200."""
4327 response = await client.get("/static/layout.css")
4328 assert response.status_code == 200
4329 assert "text/css" in response.headers.get("content-type", "")
4330 assert ".container" in response.text
4331
4332
4333 @pytest.mark.anyio
4334 async def test_icons_css_served(client: AsyncClient) -> None:
4335 """GET /static/icons.css must return 200."""
4336 response = await client.get("/static/icons.css")
4337 assert response.status_code == 200
4338 assert "text/css" in response.headers.get("content-type", "")
4339 assert ".icon-mid" in response.text
4340
4341
4342 @pytest.mark.anyio
4343 async def test_music_css_served(client: AsyncClient) -> None:
4344 """GET /static/music.css must return 200."""
4345 response = await client.get("/static/music.css")
4346 assert response.status_code == 200
4347 assert "text/css" in response.headers.get("content-type", "")
4348 assert ".piano-roll" in response.text
4349
4350
4351 @pytest.mark.anyio
4352 async def test_repo_page_uses_design_system(
4353 client: AsyncClient,
4354 db_session: AsyncSession,
4355 ) -> None:
4356 """Repo page HTML must reference the design system stylesheet via base.html.
4357
4358 app.css is the bundled design system stylesheet loaded by base.html.
4359 """
4360 await _make_repo(db_session)
4361 response = await client.get("/testuser/test-beats")
4362 assert response.status_code == 200
4363 body = response.text
4364 assert "/static/app.css" in body
4365
4366
4367 @pytest.mark.anyio
4368 async def test_responsive_meta_tag_present_repo_page(
4369 client: AsyncClient,
4370 db_session: AsyncSession,
4371 ) -> None:
4372 """Repo page must include a viewport meta tag for mobile responsiveness."""
4373 await _make_repo(db_session)
4374 response = await client.get("/testuser/test-beats")
4375 assert response.status_code == 200
4376 assert 'name="viewport"' in response.text
4377
4378
4379 @pytest.mark.anyio
4380 async def test_responsive_meta_tag_present_pr_page(
4381 client: AsyncClient,
4382 db_session: AsyncSession,
4383 ) -> None:
4384 """PR list page must include a viewport meta tag for mobile responsiveness."""
4385 await _make_repo(db_session)
4386 response = await client.get("/testuser/test-beats/pulls")
4387 assert response.status_code == 200
4388 assert 'name="viewport"' in response.text
4389
4390
4391 @pytest.mark.anyio
4392 async def test_responsive_meta_tag_present_issues_page(
4393 client: AsyncClient,
4394 db_session: AsyncSession,
4395 ) -> None:
4396 """Issues page must include a viewport meta tag for mobile responsiveness."""
4397 await _make_repo(db_session)
4398 response = await client.get("/testuser/test-beats/issues")
4399 assert response.status_code == 200
4400 assert 'name="viewport"' in response.text
4401
4402
4403 @pytest.mark.anyio
4404 async def test_design_tokens_css_contains_dimension_colors(
4405 client: AsyncClient,
4406 ) -> None:
4407 """tokens.css must define all five musical dimension color tokens.
4408
4409 These tokens are used in piano rolls, radar charts, and diff heatmaps.
4410 Missing tokens would break analysis page visualisations silently.
4411 """
4412 response = await client.get("/static/tokens.css")
4413 assert response.status_code == 200
4414 body = response.text
4415 for dim in ("harmonic", "rhythmic", "melodic", "structural", "dynamic"):
4416 assert f"--dim-{dim}:" in body, f"Missing dimension token --dim-{dim}"
4417
4418
4419 @pytest.mark.anyio
4420 async def test_design_tokens_css_contains_track_colors(
4421 client: AsyncClient,
4422 ) -> None:
4423 """tokens.css must define all 8 track color tokens (--track-0 through --track-7)."""
4424 response = await client.get("/static/tokens.css")
4425 assert response.status_code == 200
4426 body = response.text
4427 for i in range(8):
4428 assert f"--track-{i}:" in body, f"Missing track color token --track-{i}"
4429
4430
4431 @pytest.mark.anyio
4432 async def test_badge_variants_in_components_css(client: AsyncClient) -> None:
4433 """components.css must define all required badge variants including .badge-clean and .badge-dirty."""
4434 response = await client.get("/static/components.css")
4435 assert response.status_code == 200
4436 body = response.text
4437 for variant in ("open", "closed", "merged", "active", "clean", "dirty"):
4438 assert f".badge-{variant}" in body, f"Missing badge variant .badge-{variant}"
4439
4440
4441 @pytest.mark.anyio
4442 async def test_file_type_icons_in_icons_css(client: AsyncClient) -> None:
4443 """icons.css must define icon classes for all required file types."""
4444 response = await client.get("/static/icons.css")
4445 assert response.status_code == 200
4446 body = response.text
4447 for ext in ("mid", "mp3", "wav", "json", "webp", "xml", "abc"):
4448 assert f".icon-{ext}" in body, f"Missing file-type icon .icon-{ext}"
4449
4450
4451 @pytest.mark.anyio
4452 async def test_no_inline_css_on_repo_page(
4453 client: AsyncClient,
4454 db_session: AsyncSession,
4455 ) -> None:
4456 """Repo page must NOT embed the old monolithic CSS string inline.
4457
4458 Regression test: verifies the _CSS removal was not accidentally reverted.
4459 The old _CSS block contained the literal string 'background: #0d1117'
4460 inside a <style> tag in the <head>. After the design system migration,
4461 all styling comes from external files.
4462 """
4463 await _make_repo(db_session)
4464 response = await client.get("/testuser/test-beats")
4465 body = response.text
4466 # Find the <head> section — inline CSS should not appear there
4467 head_end = body.find("</head>")
4468 head_section = body[:head_end] if head_end != -1 else body
4469 # The old monolithic block started with "box-sizing: border-box"
4470 # If it appears inside <head>, the migration has been reverted.
4471 assert "box-sizing: border-box; margin: 0; padding: 0;" not in head_section
4472
4473
4474 # ---------------------------------------------------------------------------
4475 # Analysis dashboard UI tests
4476 # ---------------------------------------------------------------------------
4477
4478
4479 @pytest.mark.anyio
4480 async def test_analysis_dashboard_renders(
4481 client: AsyncClient,
4482 db_session: AsyncSession,
4483 ) -> None:
4484 """GET /{owner}/{repo_slug}/analysis/{ref} returns 200 HTML without a JWT."""
4485 await _make_repo(db_session)
4486 response = await client.get("/testuser/test-beats/analysis/main")
4487 assert response.status_code == 200
4488 assert "text/html" in response.headers["content-type"]
4489 body = response.text
4490 assert "MuseHub" in body
4491 assert "Analysis" in body
4492 assert "test-beats" in body
4493
4494
4495 @pytest.mark.anyio
4496 async def test_analysis_dashboard_no_auth_required(
4497 client: AsyncClient,
4498 db_session: AsyncSession,
4499 ) -> None:
4500 """Analysis dashboard HTML shell must be accessible without an Authorization header."""
4501 await _make_repo(db_session)
4502 response = await client.get("/testuser/test-beats/analysis/main")
4503 assert response.status_code == 200
4504 assert response.status_code != 401
4505
4506
4507 @pytest.mark.anyio
4508 async def test_analysis_dashboard_all_dimension_labels(
4509 client: AsyncClient,
4510 db_session: AsyncSession,
4511 ) -> None:
4512 """Dashboard HTML embeds all 10 required dimension card labels in the page script.
4513
4514 Regression test: if any card label is missing the JS template
4515 will silently skip rendering that dimension, so agents get an incomplete picture.
4516 """
4517 await _make_repo(db_session)
4518 response = await client.get("/testuser/test-beats/analysis/main")
4519 assert response.status_code == 200
4520 body = response.text
4521 for label in ("Key", "Tempo", "Meter", "Chord Map", "Dynamics",
4522 "Groove", "Emotion", "Form", "Motifs", "Contour"):
4523 assert label in body, f"Expected dimension label {label!r} in dashboard HTML"
4524
4525
4526 @pytest.mark.anyio
4527 async def test_analysis_dashboard_sparkline_logic_present(
4528 client: AsyncClient,
4529 db_session: AsyncSession,
4530 ) -> None:
4531 """Dashboard renders dimension cards server-side with key musical data visible in HTML.
4532
4533 Updated for SSR migration (issue #578): the dashboard now renders all dimension
4534 data via Jinja2 rather than fetching via client-side JS. Key/tempo/meter/groove/form
4535 data is embedded directly in the HTML — no JS sparkline or API fetch is needed.
4536 """
4537 await _make_repo(db_session)
4538 response = await client.get("/testuser/test-beats/analysis/main")
4539 assert response.status_code == 200
4540 body = response.text
4541 # SSR dashboard renders dimension cards with inline data (tonic, BPM, time-sig, etc.)
4542 assert "Key" in body
4543 assert "Tempo" in body
4544 assert "/analysis/" in body
4545
4546
4547 @pytest.mark.anyio
4548 async def test_analysis_dashboard_card_links_to_dimensions(
4549 client: AsyncClient,
4550 db_session: AsyncSession,
4551 ) -> None:
4552 """Each dimension card must link to the per-dimension analysis detail page.
4553
4554 The card href is built client-side from ``base + '/analysis/' + ref + '/' + id``,
4555 so the JS template string must reference ``/analysis/`` as the path segment.
4556 """
4557 await _make_repo(db_session)
4558 response = await client.get("/testuser/test-beats/analysis/main")
4559 assert response.status_code == 200
4560 body = response.text
4561 assert "/analysis/" in body
4562
4563
4564
4565
4566 # ---------------------------------------------------------------------------
4567 # Motifs browser page — # ---------------------------------------------------------------------------
4568
4569
4570 @pytest.mark.anyio
4571 async def test_motifs_page_renders(
4572 client: AsyncClient,
4573 db_session: AsyncSession,
4574 ) -> None:
4575 """GET /{owner}/{repo_slug}/analysis/{ref}/motifs returns 200 HTML."""
4576 repo_id = await _make_repo(db_session)
4577 response = await client.get("/testuser/test-beats/analysis/main/motifs")
4578 assert response.status_code == 200
4579 assert "text/html" in response.headers["content-type"]
4580 body = response.text
4581 assert "MuseHub" in body
4582
4583
4584 @pytest.mark.anyio
4585 async def test_motifs_page_no_auth_required(
4586 client: AsyncClient,
4587 db_session: AsyncSession,
4588 ) -> None:
4589 """Motifs UI page must be accessible without an Authorization header."""
4590 repo_id = await _make_repo(db_session)
4591 response = await client.get("/testuser/test-beats/analysis/main/motifs")
4592 assert response.status_code == 200
4593 assert response.status_code != 401
4594
4595
4596 @pytest.mark.anyio
4597 async def test_motifs_page_contains_filter_ui(
4598 client: AsyncClient,
4599 db_session: AsyncSession,
4600 ) -> None:
4601 """Motifs page SSR: must contain interval pattern section and occurrence markers."""
4602 repo_id = await _make_repo(db_session)
4603 response = await client.get("/testuser/test-beats/analysis/main/motifs")
4604 assert response.status_code == 200
4605 body = response.text
4606 assert "INTERVAL PATTERN" in body
4607 assert "OCCURRENCES" in body
4608
4609
4610 @pytest.mark.anyio
4611 async def test_motifs_page_contains_piano_roll_renderer(
4612 client: AsyncClient,
4613 db_session: AsyncSession,
4614 ) -> None:
4615 """Motifs page SSR: must contain motif browser heading and interval data."""
4616 repo_id = await _make_repo(db_session)
4617 response = await client.get("/testuser/test-beats/analysis/main/motifs")
4618 assert response.status_code == 200
4619 body = response.text
4620 assert "Motif Browser" in body
4621 assert "INTERVAL PATTERN" in body
4622
4623
4624 @pytest.mark.anyio
4625 async def test_motifs_page_contains_recurrence_grid(
4626 client: AsyncClient,
4627 db_session: AsyncSession,
4628 ) -> None:
4629 """Motifs page SSR: must contain recurrence grid section rendered server-side."""
4630 repo_id = await _make_repo(db_session)
4631 response = await client.get("/testuser/test-beats/analysis/main/motifs")
4632 assert response.status_code == 200
4633 body = response.text
4634 assert "RECURRENCE GRID" in body or "occurrence" in body.lower()
4635
4636
4637 @pytest.mark.anyio
4638 async def test_motifs_page_shows_transformation_badges(
4639 client: AsyncClient,
4640 db_session: AsyncSession,
4641 ) -> None:
4642 """Motifs page SSR: must contain TRANSFORMATIONS section with inversion type labels."""
4643 repo_id = await _make_repo(db_session)
4644 response = await client.get("/testuser/test-beats/analysis/main/motifs")
4645 assert response.status_code == 200
4646 body = response.text
4647 assert "TRANSFORMATIONS" in body
4648 assert "inversion" in body
4649
4650
4651 # ---------------------------------------------------------------------------
4652 # Content negotiation & repo home page tests — / #203
4653 # ---------------------------------------------------------------------------
4654
4655
4656 @pytest.mark.anyio
4657 async def test_repo_page_html_default(
4658 client: AsyncClient,
4659 db_session: AsyncSession,
4660 ) -> None:
4661 """GET /{owner}/{repo_slug} with no Accept header returns HTML by default."""
4662 await _make_repo(db_session)
4663 response = await client.get("/testuser/test-beats")
4664 assert response.status_code == 200
4665 assert "text/html" in response.headers["content-type"]
4666 body = response.text
4667 assert "MuseHub" in body
4668 assert "testuser" in body
4669 assert "test-beats" in body
4670
4671
4672 @pytest.mark.anyio
4673 async def test_repo_home_shows_stats(
4674 client: AsyncClient,
4675 db_session: AsyncSession,
4676 ) -> None:
4677 """Repo home page renders SSR stats (commit count link and hero section)."""
4678 await _make_repo(db_session)
4679 response = await client.get("/testuser/test-beats")
4680 assert response.status_code == 200
4681 body = response.text
4682 # hero-header class renamed during CSS refactor; check for repo stats content instead
4683 assert "recent commit" in body
4684
4685
4686 @pytest.mark.anyio
4687 async def test_repo_home_recent_commits(
4688 client: AsyncClient,
4689 db_session: AsyncSession,
4690 ) -> None:
4691 """Repo home page renders a SSR recent commits sidebar section."""
4692 await _make_repo(db_session)
4693 response = await client.get("/testuser/test-beats")
4694 assert response.status_code == 200
4695 body = response.text
4696 assert "Recent Commits" in body
4697 assert "sidebar-section-title" in body
4698
4699
4700 @pytest.mark.anyio
4701 async def test_repo_home_audio_player(
4702 client: AsyncClient,
4703 db_session: AsyncSession,
4704 ) -> None:
4705 """Repo home page includes the persistent floating audio player from base.html."""
4706 await _make_repo(db_session)
4707 response = await client.get("/testuser/test-beats")
4708 assert response.status_code == 200
4709 body = response.text
4710 assert 'id="audio-player"' in body
4711 assert "class=\"audio-player\"" in body
4712
4713
4714 @pytest.mark.anyio
4715 async def test_repo_page_json_accept(
4716 client: AsyncClient,
4717 db_session: AsyncSession,
4718 ) -> None:
4719 """GET /{owner}/{repo_slug} with Accept: application/json returns JSON repo data."""
4720 await _make_repo(db_session)
4721 response = await client.get(
4722 "/testuser/test-beats",
4723 headers={"Accept": "application/json"},
4724 )
4725 assert response.status_code == 200
4726 assert "application/json" in response.headers["content-type"]
4727 data = response.json()
4728 # RepoResponse fields serialised as camelCase
4729 assert "repoId" in data or "repo_id" in data or "slug" in data or "name" in data
4730
4731
4732 @pytest.mark.anyio
4733 async def test_commits_page_json_format_param(
4734 client: AsyncClient,
4735 db_session: AsyncSession,
4736 ) -> None:
4737 """GET /{owner}/{repo_slug}/commits?format=json returns JSON commit list."""
4738 await _make_repo(db_session)
4739 response = await client.get("/testuser/test-beats/commits?format=json")
4740 assert response.status_code == 200
4741 assert "application/json" in response.headers["content-type"]
4742 data = response.json()
4743 # CommitListResponse has commits (list) and total (int)
4744 assert "commits" in data
4745 assert "total" in data
4746 assert isinstance(data["commits"], list)
4747
4748
4749 @pytest.mark.anyio
4750 async def test_json_response_camelcase(
4751 client: AsyncClient,
4752 db_session: AsyncSession,
4753 ) -> None:
4754 """JSON response from repo page uses camelCase keys matching API convention."""
4755 await _make_repo(db_session)
4756 response = await client.get(
4757 "/testuser/test-beats",
4758 headers={"Accept": "application/json"},
4759 )
4760 assert response.status_code == 200
4761 data = response.json()
4762 # All top-level keys must be camelCase — no underscores allowed in field names
4763 # (Pydantic by_alias=True serialises snake_case fields as camelCase)
4764 snake_keys = [k for k in data if "_" in k]
4765 assert snake_keys == [], f"Expected camelCase keys but found snake_case: {snake_keys}"
4766
4767
4768 @pytest.mark.anyio
4769 async def test_commits_list_html_default(
4770 client: AsyncClient,
4771 db_session: AsyncSession,
4772 ) -> None:
4773 """GET /{owner}/{repo_slug}/commits with no Accept header returns HTML."""
4774 await _make_repo(db_session)
4775 response = await client.get("/testuser/test-beats/commits")
4776 assert response.status_code == 200
4777 assert "text/html" in response.headers["content-type"]
4778
4779
4780 # ---------------------------------------------------------------------------
4781 # Tree browser tests — # ---------------------------------------------------------------------------
4782
4783
4784 async def _seed_tree_fixtures(db_session: AsyncSession) -> str:
4785 """Seed a public repo with a branch and objects for tree browser tests.
4786
4787 Creates:
4788 - repo: testuser/tree-test (public)
4789 - branch: main (head pointing at a dummy commit)
4790 - objects: tracks/bass.mid, tracks/keys.mp3, metadata.json, cover.webp
4791 Returns repo_id.
4792 """
4793 repo = MusehubRepo(
4794 name="tree-test",
4795 owner="testuser",
4796 slug="tree-test",
4797 visibility="public",
4798 owner_user_id="test-owner",
4799 )
4800 db_session.add(repo)
4801 await db_session.flush()
4802
4803 commit = MusehubCommit(
4804 commit_id="abc123def456",
4805 repo_id=str(repo.repo_id),
4806 message="initial",
4807 branch="main",
4808 author="testuser",
4809 timestamp=datetime.now(tz=UTC),
4810 )
4811 db_session.add(commit)
4812
4813 branch = MusehubBranch(
4814 repo_id=str(repo.repo_id),
4815 name="main",
4816 head_commit_id="abc123def456",
4817 )
4818 db_session.add(branch)
4819
4820 for path, size in [
4821 ("tracks/bass.mid", 2048),
4822 ("tracks/keys.mp3", 8192),
4823 ("metadata.json", 512),
4824 ("cover.webp", 4096),
4825 ]:
4826 obj = MusehubObject(
4827 object_id=f"sha256:{path.replace('/', '_')}",
4828 repo_id=str(repo.repo_id),
4829 path=path,
4830 size_bytes=size,
4831 disk_path=f"/tmp/{path.replace('/', '_')}",
4832 )
4833 db_session.add(obj)
4834
4835 await db_session.commit()
4836 return str(repo.repo_id)
4837
4838
4839 @pytest.mark.anyio
4840 async def test_tree_root_lists_directories(
4841 client: AsyncClient,
4842 db_session: AsyncSession,
4843 ) -> None:
4844 """GET /{owner}/{repo}/tree/{ref} returns 200 HTML with tree JS."""
4845 await _seed_tree_fixtures(db_session)
4846 response = await client.get("/testuser/tree-test/tree/main")
4847 assert response.status_code == 200
4848 assert "text/html" in response.headers["content-type"]
4849 body = response.text
4850 assert "tree" in body
4851 assert "branch-sel" in body or "ref-selector" in body or "loadTree" in body
4852
4853
4854 @pytest.mark.anyio
4855 async def test_tree_subdirectory_lists_files(
4856 client: AsyncClient,
4857 db_session: AsyncSession,
4858 ) -> None:
4859 """GET /{owner}/{repo}/tree/{ref}/tracks returns 200 HTML for the subdirectory."""
4860 await _seed_tree_fixtures(db_session)
4861 response = await client.get("/testuser/tree-test/tree/main/tracks")
4862 assert response.status_code == 200
4863 assert "text/html" in response.headers["content-type"]
4864 body = response.text
4865 assert "tracks" in body
4866 assert "loadTree" in body
4867
4868
4869 @pytest.mark.anyio
4870 async def test_tree_file_icons_by_type(
4871 client: AsyncClient,
4872 db_session: AsyncSession,
4873 ) -> None:
4874 """Tree template includes JS that maps extensions to file-type icons."""
4875 await _seed_tree_fixtures(db_session)
4876 response = await client.get("/testuser/tree-test/tree/main")
4877 assert response.status_code == 200
4878 body = response.text
4879 # Piano icon for .mid files
4880 assert ".mid" in body or "midi" in body
4881 # Waveform icon for .mp3/.wav files
4882 assert ".mp3" in body or ".wav" in body
4883 # Braces for .json
4884 assert ".json" in body
4885 # Photo for images
4886 assert ".webp" in body or ".png" in body
4887
4888
4889 @pytest.mark.anyio
4890 async def test_tree_breadcrumbs_correct(
4891 client: AsyncClient,
4892 db_session: AsyncSession,
4893 ) -> None:
4894 """Tree page breadcrumb contains owner, repo, tree, and ref."""
4895 await _seed_tree_fixtures(db_session)
4896 response = await client.get("/testuser/tree-test/tree/main")
4897 assert response.status_code == 200
4898 body = response.text
4899 assert "testuser" in body
4900 assert "tree-test" in body
4901 assert "tree" in body
4902 assert "main" in body
4903
4904
4905 @pytest.mark.anyio
4906 async def test_tree_json_response(
4907 client: AsyncClient,
4908 db_session: AsyncSession,
4909 ) -> None:
4910 """GET /api/v1/repos/{repo_id}/tree/{ref} returns JSON with tree entries."""
4911 repo_id = await _seed_tree_fixtures(db_session)
4912 response = await client.get(
4913 f"/api/v1/repos/{repo_id}/tree/main"
4914 f"?owner=testuser&repo_slug=tree-test"
4915 )
4916 assert response.status_code == 200
4917 data = response.json()
4918 assert "entries" in data
4919 assert data["ref"] == "main"
4920 assert data["dirPath"] == ""
4921 # Root should show: 'tracks' dir, 'metadata.json', 'cover.webp'
4922 names = {e["name"] for e in data["entries"]}
4923 assert "tracks" in names
4924 assert "metadata.json" in names
4925 assert "cover.webp" in names
4926 # 'bass.mid' should NOT appear at root (it's under tracks/)
4927 assert "bass.mid" not in names
4928 # tracks entry must be a directory
4929 tracks_entry = next(e for e in data["entries"] if e["name"] == "tracks")
4930 assert tracks_entry["type"] == "dir"
4931 assert tracks_entry["sizeBytes"] is None
4932
4933
4934 @pytest.mark.anyio
4935 async def test_tree_unknown_ref_404(
4936 client: AsyncClient,
4937 db_session: AsyncSession,
4938 ) -> None:
4939 """GET /api/v1/repos/{repo_id}/tree/{unknown_ref} returns 404."""
4940 repo_id = await _seed_tree_fixtures(db_session)
4941 response = await client.get(
4942 f"/api/v1/repos/{repo_id}/tree/does-not-exist"
4943 f"?owner=testuser&repo_slug=tree-test"
4944 )
4945 assert response.status_code == 404
4946
4947
4948 # ---------------------------------------------------------------------------
4949 # Harmony analysis page tests — # ---------------------------------------------------------------------------
4950
4951
4952 @pytest.mark.anyio
4953 async def test_harmony_page_renders(
4954 client: AsyncClient,
4955 db_session: AsyncSession,
4956 ) -> None:
4957 """GET /{owner}/{repo_slug}/analysis/{ref}/harmony returns 200 SSR HTML."""
4958 await _make_repo(db_session)
4959 ref = "abc1234567890abcdef"
4960 response = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
4961 assert response.status_code == 200
4962 assert "text/html" in response.headers["content-type"]
4963 body = response.text
4964 assert "MuseHub" in body
4965 assert "Harmony Analysis" in body
4966
4967
4968 @pytest.mark.anyio
4969 async def test_harmony_page_no_auth_required(
4970 client: AsyncClient,
4971 db_session: AsyncSession,
4972 ) -> None:
4973 """Harmony analysis SSR page must be accessible without a JWT (not 401)."""
4974 await _make_repo(db_session)
4975 ref = "deadbeef00001234"
4976 response = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
4977 assert response.status_code != 401
4978 assert response.status_code == 200
4979
4980
4981 @pytest.mark.anyio
4982 async def test_harmony_page_contains_key_display(
4983 client: AsyncClient,
4984 db_session: AsyncSession,
4985 ) -> None:
4986 """Harmony SSR page must render key and mode summary from HarmonyAnalysisResponse."""
4987 await _make_repo(db_session)
4988 ref = "cafe0000000000000001"
4989 response = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
4990 assert response.status_code == 200
4991 body = response.text
4992 # SSR template renders key summary card with harmony_data.key (full key label e.g. "F major"),
4993 # harmony_data.mode (e.g. "major"), and harmonic_rhythm_bpm as "chords/min"
4994 assert "Harmony Analysis" in body
4995 assert "CHORD EVENTS" in body
4996 assert "chords/min" in body
4997
4998
4999 @pytest.mark.anyio
5000 async def test_harmony_page_contains_chord_timeline(
5001 client: AsyncClient,
5002 db_session: AsyncSession,
5003 ) -> None:
5004 """Harmony SSR page must render the Roman-numeral chord events section."""
5005 await _make_repo(db_session)
5006 ref = "babe0000000000000002"
5007 response = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
5008 assert response.status_code == 200
5009 body = response.text
5010 # SSR template renders a CHORD EVENTS card with Roman numeral symbols
5011 assert "CHORD EVENTS" in body
5012
5013
5014 @pytest.mark.anyio
5015 async def test_harmony_page_contains_tension_curve(
5016 client: AsyncClient,
5017 db_session: AsyncSession,
5018 ) -> None:
5019 """Harmony SSR page must render the cadences section (replaces the old tension-curve card)."""
5020 await _make_repo(db_session)
5021 ref = "face0000000000000003"
5022 response = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
5023 assert response.status_code == 200
5024 body = response.text
5025 # SSR template renders a CADENCES card (server-side, no JS SVG renderer needed)
5026 assert "CADENCES" in body
5027
5028
5029 @pytest.mark.anyio
5030 async def test_harmony_page_contains_modulation_section(
5031 client: AsyncClient,
5032 db_session: AsyncSession,
5033 ) -> None:
5034 """Harmony SSR page must render the MODULATIONS card server-side."""
5035 await _make_repo(db_session)
5036 ref = "feed0000000000000004"
5037 response = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
5038 assert response.status_code == 200
5039 body = response.text
5040 # SSR template renders a MODULATIONS card from harmony_data.modulations
5041 assert "MODULATIONS" in body
5042
5043
5044 @pytest.mark.anyio
5045 async def test_harmony_page_contains_filter_controls(
5046 client: AsyncClient,
5047 db_session: AsyncSession,
5048 ) -> None:
5049 """Harmony SSR page must include HTMX fragment support (HX-Request returns partial HTML)."""
5050 await _make_repo(db_session)
5051 ref = "beef0000000000000005"
5052 # Full page response
5053 full = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
5054 assert full.status_code == 200
5055 assert "<html" in full.text
5056 # HTMX fragment response (no outer HTML wrapper)
5057 fragment = await client.get(
5058 f"/testuser/test-beats/analysis/{ref}/harmony",
5059 headers={"HX-Request": "true"},
5060 )
5061 assert fragment.status_code == 200
5062 assert "<html" not in fragment.text
5063 assert "Harmony Analysis" in fragment.text
5064
5065
5066 @pytest.mark.anyio
5067 async def test_harmony_page_contains_key_history(
5068 client: AsyncClient,
5069 db_session: AsyncSession,
5070 ) -> None:
5071 """Harmony SSR page must render breadcrumb with owner/repo_slug/analysis path."""
5072 await _make_repo(db_session)
5073 ref = "0000000000000000dead"
5074 response = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
5075 assert response.status_code == 200
5076 body = response.text
5077 # SSR template breadcrumb shows owner, repo_slug, and analysis path
5078 assert "testuser" in body
5079 assert "test-beats" in body
5080 assert "analysis" in body
5081
5082
5083 @pytest.mark.anyio
5084 async def test_harmony_page_contains_voice_leading(
5085 client: AsyncClient,
5086 db_session: AsyncSession,
5087 ) -> None:
5088 """Harmony SSR page must render harmonic rhythm (replaces the old voice-leading JS card)."""
5089 await _make_repo(db_session)
5090 ref = "1111111111111111beef"
5091 response = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
5092 assert response.status_code == 200
5093 body = response.text
5094 # SSR template renders harmonic_rhythm_bpm as "chords/min" in the key summary card
5095 assert "chords/min" in body
5096
5097
5098 @pytest.mark.anyio
5099 async def test_harmony_page_has_token_form(
5100 client: AsyncClient,
5101 db_session: AsyncSession,
5102 ) -> None:
5103 """Harmony SSR page includes JWT token form and app.js via base.html layout."""
5104 await _make_repo(db_session)
5105 ref = "2222222222222222cafe"
5106 response = await client.get(f"/testuser/test-beats/analysis/{ref}/harmony")
5107 assert response.status_code == 200
5108 body = response.text
5109 assert 'id="token-form"' in body
5110 assert "app.js" in body
5111
5112
5113 @pytest.mark.anyio
5114 async def test_harmony_json_response(
5115 client: AsyncClient,
5116 db_session: AsyncSession,
5117 auth_headers: dict[str, str],
5118 ) -> None:
5119 """GET /api/v1/repos/{repo_id}/analysis/{ref}/harmony returns HarmonyAnalysisResponse."""
5120 repo_id = await _make_repo(db_session)
5121 resp = await client.get(
5122 f"/api/v1/repos/{repo_id}/analysis/main/harmony",
5123 headers=auth_headers,
5124 )
5125 assert resp.status_code == 200
5126 body = resp.json()
5127 # Dedicated harmony endpoint returns HarmonyAnalysisResponse (not the generic AnalysisResponse
5128 # envelope). Fields are camelCase from CamelModel.
5129 assert "key" in body
5130 assert "mode" in body
5131 assert "romanNumerals" in body
5132 assert "cadences" in body
5133 assert "modulations" in body
5134 assert "harmonicRhythmBpm" in body
5135 assert isinstance(body["romanNumerals"], list)
5136 assert isinstance(body["cadences"], list)
5137 assert isinstance(body["modulations"], list)
5138 assert isinstance(body["harmonicRhythmBpm"], float | int)
5139
5140 # Listen page tests
5141 # ---------------------------------------------------------------------------
5142
5143
5144 async def _seed_listen_fixtures(db_session: AsyncSession) -> str:
5145 """Seed a repo with audio objects for listen-page tests; return repo_id."""
5146 repo = MusehubRepo(
5147 name="listen-test",
5148 owner="testuser",
5149 slug="listen-test",
5150 visibility="public",
5151 owner_user_id="test-owner",
5152 )
5153 db_session.add(repo)
5154 await db_session.commit()
5155 await db_session.refresh(repo)
5156 repo_id = str(repo.repo_id)
5157
5158 for path, size in [
5159 ("mix/full_mix.mp3", 204800),
5160 ("tracks/bass.mp3", 51200),
5161 ("tracks/keys.mp3", 61440),
5162 ("tracks/bass.webp", 8192),
5163 ]:
5164 obj = MusehubObject(
5165 object_id=f"sha256:{path.replace('/', '_')}",
5166 repo_id=repo_id,
5167 path=path,
5168 size_bytes=size,
5169 disk_path=f"/tmp/{path.replace('/', '_')}",
5170 )
5171 db_session.add(obj)
5172 await db_session.commit()
5173 return repo_id
5174
5175
5176 @pytest.mark.anyio
5177 async def test_listen_page_full_mix(
5178 client: AsyncClient,
5179 db_session: AsyncSession,
5180 ) -> None:
5181 """GET /{owner}/{repo}/listen/{ref} returns 200 HTML with player UI."""
5182 await _seed_listen_fixtures(db_session)
5183 ref = "main"
5184 response = await client.get(f"/testuser/listen-test/listen/{ref}")
5185 assert response.status_code == 200
5186 assert "text/html" in response.headers["content-type"]
5187 body = response.text
5188 assert "MuseHub" in body
5189 assert "listen" in body.lower()
5190 # Full-mix player elements present
5191 assert "mix-play-btn" in body
5192 assert "mix-progress-bar" in body
5193
5194
5195 @pytest.mark.anyio
5196 async def test_listen_page_track_listing(
5197 client: AsyncClient,
5198 db_session: AsyncSession,
5199 ) -> None:
5200 """Listen page HTML embeds track-listing JS that renders per-track controls."""
5201 await _seed_listen_fixtures(db_session)
5202 ref = "main"
5203 response = await client.get(f"/testuser/listen-test/listen/{ref}")
5204 assert response.status_code == 200
5205 body = response.text
5206 # Track-listing JavaScript is embedded
5207 assert "track-list" in body
5208 assert "track-play-btn" in body or "playTrack" in body
5209
5210
5211 @pytest.mark.anyio
5212 async def test_listen_page_no_renders_fallback(
5213 client: AsyncClient,
5214 db_session: AsyncSession,
5215 ) -> None:
5216 """Listen page renders a friendly fallback when no audio artifacts exist."""
5217 # Repo with no objects at all
5218 repo = MusehubRepo(
5219 name="silent-repo",
5220 owner="testuser",
5221 slug="silent-repo",
5222 visibility="public",
5223 owner_user_id="test-owner",
5224 )
5225 db_session.add(repo)
5226 await db_session.commit()
5227
5228 response = await client.get("/testuser/silent-repo/listen/main")
5229 assert response.status_code == 200
5230 body = response.text
5231 # Fallback UI marker present (no-renders state)
5232 assert "no-renders" in body or "No audio" in body or "hasRenders" in body
5233
5234
5235 @pytest.mark.anyio
5236 async def test_listen_page_json_response(
5237 client: AsyncClient,
5238 db_session: AsyncSession,
5239 ) -> None:
5240 """GET /{owner}/{repo}/listen/{ref}?format=json returns TrackListingResponse."""
5241 await _seed_listen_fixtures(db_session)
5242 ref = "main"
5243 response = await client.get(
5244 f"/testuser/listen-test/listen/{ref}",
5245 params={"format": "json"},
5246 )
5247 assert response.status_code == 200
5248 assert "application/json" in response.headers["content-type"]
5249 body = response.json()
5250 assert "repoId" in body
5251 assert "ref" in body
5252 assert body["ref"] == ref
5253 assert "tracks" in body
5254 assert "hasRenders" in body
5255 assert isinstance(body["tracks"], list)
5256
5257
5258 # ---------------------------------------------------------------------------
5259 # Issue #366 — musehub_listen service function (direct unit tests)
5260 # ---------------------------------------------------------------------------
5261
5262
5263 @pytest.mark.anyio
5264 async def test_build_track_listing_returns_full_mix_and_tracks(
5265 db_session: AsyncSession,
5266 ) -> None:
5267 """build_track_listing() returns a populated TrackListingResponse with mix + stems."""
5268 from musehub.services.musehub_listen import build_track_listing
5269
5270 repo = MusehubRepo(
5271 name="svc-listen-test",
5272 owner="svcuser",
5273 slug="svc-listen-test",
5274 visibility="public",
5275 owner_user_id="svc-owner",
5276 )
5277 db_session.add(repo)
5278 await db_session.commit()
5279 await db_session.refresh(repo)
5280 repo_id = str(repo.repo_id)
5281
5282 for path, size in [
5283 ("mix/full_mix.mp3", 204800),
5284 ("tracks/bass.mp3", 51200),
5285 ("tracks/keys.mp3", 61440),
5286 ("tracks/bass.webp", 8192),
5287 ]:
5288 obj = MusehubObject(
5289 object_id=f"sha256:svc_{path.replace('/', '_')}",
5290 repo_id=repo_id,
5291 path=path,
5292 size_bytes=size,
5293 disk_path=f"/tmp/svc_{path.replace('/', '_')}",
5294 )
5295 db_session.add(obj)
5296 await db_session.commit()
5297
5298 result = await build_track_listing(db_session, repo_id, "main")
5299
5300 assert result.has_renders is True
5301 assert result.repo_id == repo_id
5302 assert result.ref == "main"
5303 # full-mix URL points to the mix file (contains "mix" keyword)
5304 assert result.full_mix_url is not None
5305 assert "full_mix" in result.full_mix_url or "mix" in result.full_mix_url
5306 # Two audio tracks (bass.mp3 + keys.mp3); bass.webp is not audio
5307 assert len(result.tracks) == 3 # mix/full_mix.mp3, tracks/bass.mp3, tracks/keys.mp3
5308 track_paths = {t.path for t in result.tracks}
5309 assert "tracks/bass.mp3" in track_paths
5310 assert "tracks/keys.mp3" in track_paths
5311 # Piano-roll URL attached to bass.mp3 (matching bass.webp exists)
5312 bass_track = next(t for t in result.tracks if t.path == "tracks/bass.mp3")
5313 assert bass_track.piano_roll_url is not None
5314
5315
5316 @pytest.mark.anyio
5317 async def test_build_track_listing_no_audio_returns_empty(
5318 db_session: AsyncSession,
5319 ) -> None:
5320 """build_track_listing() returns has_renders=False when no audio objects exist."""
5321 from musehub.services.musehub_listen import build_track_listing
5322
5323 repo = MusehubRepo(
5324 name="svc-silent-test",
5325 owner="svcuser",
5326 slug="svc-silent-test",
5327 visibility="public",
5328 owner_user_id="svc-owner",
5329 )
5330 db_session.add(repo)
5331 await db_session.commit()
5332 await db_session.refresh(repo)
5333 repo_id = str(repo.repo_id)
5334
5335 # Only a non-audio object
5336 obj = MusehubObject(
5337 object_id="sha256:svc_midi",
5338 repo_id=repo_id,
5339 path="tracks/bass.mid",
5340 size_bytes=1024,
5341 disk_path="/tmp/svc_bass.mid",
5342 )
5343 db_session.add(obj)
5344 await db_session.commit()
5345
5346 result = await build_track_listing(db_session, repo_id, "dev")
5347
5348 assert result.has_renders is False
5349 assert result.full_mix_url is None
5350 assert result.tracks == []
5351
5352
5353 @pytest.mark.anyio
5354 async def test_build_track_listing_no_mix_keyword_uses_first_alphabetically(
5355 db_session: AsyncSession,
5356 ) -> None:
5357 """When no file matches _FULL_MIX_KEYWORDS, the first audio file (by path) is used."""
5358 from musehub.services.musehub_listen import build_track_listing
5359
5360 repo = MusehubRepo(
5361 name="svc-nomix-test",
5362 owner="svcuser",
5363 slug="svc-nomix-test",
5364 visibility="public",
5365 owner_user_id="svc-owner",
5366 )
5367 db_session.add(repo)
5368 await db_session.commit()
5369 await db_session.refresh(repo)
5370 repo_id = str(repo.repo_id)
5371
5372 for path, size in [
5373 ("tracks/bass.mp3", 51200),
5374 ("tracks/drums.mp3", 61440),
5375 ]:
5376 obj = MusehubObject(
5377 object_id=f"sha256:svc_nomix_{path.replace('/', '_')}",
5378 repo_id=repo_id,
5379 path=path,
5380 size_bytes=size,
5381 disk_path=f"/tmp/svc_nomix_{path.replace('/', '_')}",
5382 )
5383 db_session.add(obj)
5384 await db_session.commit()
5385
5386 result = await build_track_listing(db_session, repo_id, "main")
5387
5388 assert result.has_renders is True
5389 # 'tracks/bass.mp3' sorts before 'tracks/drums.mp3'
5390 assert result.full_mix_url is not None
5391 assert "bass" in result.full_mix_url
5392
5393
5394 # ---------------------------------------------------------------------------
5395 # Issue #206 — Commit list page
5396 # ---------------------------------------------------------------------------
5397
5398 _COMMIT_LIST_OWNER = "commitowner"
5399 _COMMIT_LIST_SLUG = "commit-list-repo"
5400 _SHA_MAIN_1 = "aa001122334455667788990011223344556677889900"
5401 _SHA_MAIN_2 = "bb001122334455667788990011223344556677889900"
5402 _SHA_MAIN_MERGE = "cc001122334455667788990011223344556677889900"
5403 _SHA_FEAT = "ff001122334455667788990011223344556677889900"
5404
5405
5406 async def _seed_commit_list_repo(
5407 db_session: AsyncSession,
5408 ) -> str:
5409 """Seed a repo with 2 commits on main, 1 merge commit, and 1 on feat branch."""
5410 repo = MusehubRepo(
5411 name=_COMMIT_LIST_SLUG,
5412 owner=_COMMIT_LIST_OWNER,
5413 slug=_COMMIT_LIST_SLUG,
5414 visibility="public",
5415 owner_user_id="commit-owner-uid",
5416 )
5417 db_session.add(repo)
5418 await db_session.flush()
5419 repo_id = str(repo.repo_id)
5420
5421 branch_main = MusehubBranch(repo_id=repo_id, name="main", head_commit_id=_SHA_MAIN_MERGE)
5422 branch_feat = MusehubBranch(repo_id=repo_id, name="feat/drums", head_commit_id=_SHA_FEAT)
5423 db_session.add_all([branch_main, branch_feat])
5424
5425 now = datetime.now(UTC)
5426 commits = [
5427 MusehubCommit(
5428 commit_id=_SHA_MAIN_1,
5429 repo_id=repo_id,
5430 branch="main",
5431 parent_ids=[],
5432 message="feat(bass): root commit with walking bass line",
5433 author="composer@muse.app",
5434 timestamp=now - timedelta(hours=4),
5435 ),
5436 MusehubCommit(
5437 commit_id=_SHA_MAIN_2,
5438 repo_id=repo_id,
5439 branch="main",
5440 parent_ids=[_SHA_MAIN_1],
5441 message="feat(keys): add rhodes chord voicings in verse",
5442 author="composer@muse.app",
5443 timestamp=now - timedelta(hours=2),
5444 ),
5445 MusehubCommit(
5446 commit_id=_SHA_MAIN_MERGE,
5447 repo_id=repo_id,
5448 branch="main",
5449 parent_ids=[_SHA_MAIN_2, _SHA_FEAT],
5450 message="merge(feat/drums): integrate drum pattern into main",
5451 author="composer@muse.app",
5452 timestamp=now - timedelta(hours=1),
5453 ),
5454 MusehubCommit(
5455 commit_id=_SHA_FEAT,
5456 repo_id=repo_id,
5457 branch="feat/drums",
5458 parent_ids=[_SHA_MAIN_1],
5459 message="feat(drums): add kick and snare pattern at 120 BPM",
5460 author="drummer@muse.app",
5461 timestamp=now - timedelta(hours=3),
5462 ),
5463 ]
5464 db_session.add_all(commits)
5465 await db_session.commit()
5466 return repo_id
5467
5468
5469 @pytest.mark.anyio
5470 async def test_commits_list_page_returns_200(
5471 client: AsyncClient,
5472 db_session: AsyncSession,
5473 ) -> None:
5474 """GET /{owner}/{repo}/commits returns 200 HTML."""
5475 await _seed_commit_list_repo(db_session)
5476 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5477 assert resp.status_code == 200
5478 assert "text/html" in resp.headers["content-type"]
5479 assert "MuseHub" in resp.text
5480
5481
5482 @pytest.mark.anyio
5483 async def test_commits_list_page_shows_commit_sha(
5484 client: AsyncClient,
5485 db_session: AsyncSession,
5486 ) -> None:
5487 """Commit SHA (first 8 chars) appears in the rendered HTML."""
5488 await _seed_commit_list_repo(db_session)
5489 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5490 assert resp.status_code == 200
5491 # All 4 commits should appear (per_page=30 default, total=4)
5492 assert _SHA_MAIN_1[:8] in resp.text
5493 assert _SHA_MAIN_2[:8] in resp.text
5494 assert _SHA_MAIN_MERGE[:8] in resp.text
5495 assert _SHA_FEAT[:8] in resp.text
5496
5497
5498 @pytest.mark.anyio
5499 async def test_commits_list_page_shows_commit_message(
5500 client: AsyncClient,
5501 db_session: AsyncSession,
5502 ) -> None:
5503 """Commit messages appear truncated in commit rows."""
5504 await _seed_commit_list_repo(db_session)
5505 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5506 assert resp.status_code == 200
5507 assert "walking bass line" in resp.text
5508 assert "rhodes chord voicings" in resp.text
5509
5510
5511 @pytest.mark.anyio
5512 async def test_commits_list_page_dag_indicator(
5513 client: AsyncClient,
5514 db_session: AsyncSession,
5515 ) -> None:
5516 """DAG node CSS class is present in the HTML for every commit row."""
5517 await _seed_commit_list_repo(db_session)
5518 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5519 assert resp.status_code == 200
5520 assert "dag-node" in resp.text
5521 assert "commit-list-row" in resp.text
5522
5523
5524 @pytest.mark.anyio
5525 async def test_commits_list_page_merge_indicator(
5526 client: AsyncClient,
5527 db_session: AsyncSession,
5528 ) -> None:
5529 """Merge commits display the merge indicator and dag-node-merge class."""
5530 await _seed_commit_list_repo(db_session)
5531 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5532 assert resp.status_code == 200
5533 assert "dag-node-merge" in resp.text
5534 assert "merge" in resp.text.lower()
5535
5536
5537 @pytest.mark.anyio
5538 async def test_commits_list_page_branch_selector(
5539 client: AsyncClient,
5540 db_session: AsyncSession,
5541 ) -> None:
5542 """Branch <select> dropdown is present when the repo has branches."""
5543 await _seed_commit_list_repo(db_session)
5544 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5545 assert resp.status_code == 200
5546 # Select element with branch options
5547 assert "branch-sel" in resp.text
5548 assert "main" in resp.text
5549 assert "feat/drums" in resp.text
5550
5551
5552 @pytest.mark.anyio
5553 async def test_commits_list_page_graph_link(
5554 client: AsyncClient,
5555 db_session: AsyncSession,
5556 ) -> None:
5557 """Link to the DAG graph page is present."""
5558 await _seed_commit_list_repo(db_session)
5559 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5560 assert resp.status_code == 200
5561 assert "/graph" in resp.text
5562
5563
5564 @pytest.mark.anyio
5565 async def test_commits_list_page_pagination_links(
5566 client: AsyncClient,
5567 db_session: AsyncSession,
5568 ) -> None:
5569 """Pagination nav links appear when total exceeds per_page."""
5570 await _seed_commit_list_repo(db_session)
5571 # Request per_page=2 so 4 commits produce 2 pages
5572 resp = await client.get(
5573 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=1"
5574 )
5575 assert resp.status_code == 200
5576 body = resp.text
5577 # "Older" link should be active (page 1 has no "Newer")
5578 assert "Older" in body
5579 # "Newer" should be disabled on page 1
5580 assert "Newer" in body
5581 assert "page=2" in body
5582
5583
5584 @pytest.mark.anyio
5585 async def test_commits_list_page_pagination_page2(
5586 client: AsyncClient,
5587 db_session: AsyncSession,
5588 ) -> None:
5589 """Page 2 renders with Newer navigation active."""
5590 await _seed_commit_list_repo(db_session)
5591 resp = await client.get(
5592 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=2"
5593 )
5594 assert resp.status_code == 200
5595 body = resp.text
5596 assert "page=1" in body # "Newer" link points back to page 1
5597
5598
5599 @pytest.mark.anyio
5600 async def test_commits_list_page_branch_filter_html(
5601 client: AsyncClient,
5602 db_session: AsyncSession,
5603 ) -> None:
5604 """?branch=main returns only main-branch commits in HTML."""
5605 await _seed_commit_list_repo(db_session)
5606 resp = await client.get(
5607 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?branch=main"
5608 )
5609 assert resp.status_code == 200
5610 body = resp.text
5611 # main commits appear
5612 assert _SHA_MAIN_1[:8] in body
5613 assert _SHA_MAIN_2[:8] in body
5614 assert _SHA_MAIN_MERGE[:8] in body
5615 # feat/drums commit should NOT appear when filtered to main
5616 assert _SHA_FEAT[:8] not in body
5617
5618
5619 @pytest.mark.anyio
5620 async def test_commits_list_page_json_content_negotiation(
5621 client: AsyncClient,
5622 db_session: AsyncSession,
5623 ) -> None:
5624 """?format=json returns CommitListResponse JSON with commits and total."""
5625 await _seed_commit_list_repo(db_session)
5626 resp = await client.get(
5627 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?format=json"
5628 )
5629 assert resp.status_code == 200
5630 assert "application/json" in resp.headers["content-type"]
5631 body = resp.json()
5632 assert "commits" in body
5633 assert "total" in body
5634 assert body["total"] == 4
5635 assert len(body["commits"]) == 4
5636 # Commits are newest first; merge commit has timestamp now-1h (most recent)
5637 commit_ids = [c["commitId"] for c in body["commits"]]
5638 assert commit_ids[0] == _SHA_MAIN_MERGE
5639
5640
5641 @pytest.mark.anyio
5642 async def test_commits_list_page_json_pagination(
5643 client: AsyncClient,
5644 db_session: AsyncSession,
5645 ) -> None:
5646 """JSON with per_page=1&page=2 returns the second commit."""
5647 await _seed_commit_list_repo(db_session)
5648 resp = await client.get(
5649 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits"
5650 "?format=json&per_page=1&page=2"
5651 )
5652 assert resp.status_code == 200
5653 body = resp.json()
5654 assert body["total"] == 4
5655 assert len(body["commits"]) == 1
5656 # Page 2 (newest-first) is the second most-recent commit.
5657 # Newest: _SHA_MAIN_MERGE (now-1h), then _SHA_MAIN_2 (now-2h)
5658 assert body["commits"][0]["commitId"] == _SHA_MAIN_2
5659
5660
5661 @pytest.mark.anyio
5662 async def test_commits_list_page_empty_state(
5663 client: AsyncClient,
5664 db_session: AsyncSession,
5665 ) -> None:
5666 """A repo with no commits shows the empty state message."""
5667 repo = MusehubRepo(
5668 name="empty-repo",
5669 owner="emptyowner",
5670 slug="empty-repo",
5671 visibility="public",
5672 owner_user_id="empty-owner-uid",
5673 )
5674 db_session.add(repo)
5675 await db_session.commit()
5676
5677 resp = await client.get("/emptyowner/empty-repo/commits")
5678 assert resp.status_code == 200
5679 assert "No commits yet" in resp.text or "muse push" in resp.text
5680
5681
5682 # ---------------------------------------------------------------------------
5683
5684
5685
5686 # ---------------------------------------------------------------------------
5687 # Commit detail enhancements — # ---------------------------------------------------------------------------
5688
5689
5690 async def _seed_commit_detail_fixtures(
5691 db_session: AsyncSession,
5692 ) -> tuple[str, str, str]:
5693 """Seed a public repo with a parent commit and a child commit.
5694
5695 Returns (repo_id, parent_commit_id, child_commit_id).
5696 """
5697 repo = MusehubRepo(
5698 name="commit-detail-test",
5699 owner="testuser",
5700 slug="commit-detail-test",
5701 visibility="public",
5702 owner_user_id="test-owner",
5703 )
5704 db_session.add(repo)
5705 await db_session.flush()
5706 repo_id = str(repo.repo_id)
5707
5708 branch = MusehubBranch(
5709 repo_id=repo_id,
5710 name="main",
5711 head_commit_id=None,
5712 )
5713 db_session.add(branch)
5714
5715 parent_commit_id = "aaaa0000111122223333444455556666aaaabbbb"
5716 child_commit_id = "bbbb1111222233334444555566667777bbbbcccc"
5717
5718 parent_commit = MusehubCommit(
5719 repo_id=repo_id,
5720 commit_id=parent_commit_id,
5721 branch="main",
5722 parent_ids=[],
5723 message="init: establish harmonic foundation in C major\n\nKey: C major\nBPM: 120\nMeter: 4/4",
5724 author="testuser",
5725 timestamp=datetime.now(UTC) - timedelta(hours=2),
5726 snapshot_id=None,
5727 )
5728 child_commit = MusehubCommit(
5729 repo_id=repo_id,
5730 commit_id=child_commit_id,
5731 branch="main",
5732 parent_ids=[parent_commit_id],
5733 message="feat(keys): add melodic piano phrase in D minor\n\nKey: D minor\nBPM: 132\nMeter: 3/4\nSection: verse",
5734 author="testuser",
5735 timestamp=datetime.now(UTC) - timedelta(hours=1),
5736 snapshot_id=None,
5737 )
5738 db_session.add(parent_commit)
5739 db_session.add(child_commit)
5740 await db_session.commit()
5741 return repo_id, parent_commit_id, child_commit_id
5742
5743
5744 @pytest.mark.anyio
5745 async def test_commit_detail_page_renders_enhanced_metadata(
5746 client: AsyncClient,
5747 db_session: AsyncSession,
5748 ) -> None:
5749 """Commit detail page SSR renders commit header fields (SHA, author, branch, parent link)."""
5750 await _seed_commit_detail_fixtures(db_session)
5751 sha = "bbbb1111222233334444555566667777bbbbcccc"
5752 response = await client.get(f"/testuser/commit-detail-test/commits/{sha}")
5753 assert response.status_code == 200
5754 assert "text/html" in response.headers["content-type"]
5755 body = response.text
5756 # SSR commit header — short SHA present
5757 assert "bbbb1111" in body
5758 # Author field rendered server-side
5759 assert "testuser" in body
5760 # Parent SHA navigation link present
5761 assert "aaaa0000" in body
5762
5763
5764 @pytest.mark.anyio
5765 async def test_commit_detail_audio_shell_with_snapshot_id(
5766 client: AsyncClient,
5767 db_session: AsyncSession,
5768 ) -> None:
5769 """Commit with snapshot_id gets a WaveSurfer shell rendered by the server."""
5770 from datetime import datetime, timezone
5771
5772 _repo_id, _parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5773 repo = MusehubRepo(
5774 name="audio-test-repo",
5775 owner="testuser",
5776 slug="audio-test-repo",
5777 visibility="public",
5778 owner_user_id="test-owner",
5779 )
5780 db_session.add(repo)
5781 await db_session.flush()
5782 snap_id = "sha256:deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678"
5783 commit_with_audio = MusehubCommit(
5784 commit_id="cccc2222333344445555666677778888ccccdddd",
5785 repo_id=str(repo.repo_id),
5786 branch="main",
5787 parent_ids=[],
5788 message="Commit with audio snapshot",
5789 author="testuser",
5790 timestamp=datetime.now(tz=timezone.utc),
5791 snapshot_id=snap_id,
5792 )
5793 db_session.add(commit_with_audio)
5794 await db_session.commit()
5795
5796 response = await client.get(
5797 f"/testuser/audio-test-repo/commits/cccc2222333344445555666677778888ccccdddd"
5798 )
5799 assert response.status_code == 200
5800 body = response.text
5801 assert "commit-waveform" in body
5802 assert snap_id in body
5803
5804
5805 @pytest.mark.anyio
5806 async def test_commit_detail_ssr_message_present_in_body(
5807 client: AsyncClient,
5808 db_session: AsyncSession,
5809 ) -> None:
5810 """Commit message text is rendered in the SSR page body (replaces JS renderCommitBody)."""
5811 await _seed_commit_detail_fixtures(db_session)
5812 sha = "bbbb1111222233334444555566667777bbbbcccc"
5813 response = await client.get(f"/testuser/commit-detail-test/commits/{sha}")
5814 assert response.status_code == 200
5815 body = response.text
5816 # SSR renders the commit message directly — no JS renderCommitBody needed
5817 assert "feat(keys): add melodic piano phrase in D minor" in body
5818
5819
5820 @pytest.mark.anyio
5821 async def test_commit_detail_diff_summary_endpoint_returns_five_dimensions(
5822 client: AsyncClient,
5823 db_session: AsyncSession,
5824 auth_headers: dict[str, str],
5825 ) -> None:
5826 """GET /api/v1/repos/{repo_id}/commits/{sha}/diff-summary returns 5 dimensions."""
5827 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5828 response = await client.get(
5829 f"/api/v1/repos/{repo_id}/commits/{child_id}/diff-summary",
5830 headers=auth_headers,
5831 )
5832 assert response.status_code == 200
5833 data = response.json()
5834 assert data["commitId"] == child_id
5835 assert data["parentId"] == _parent_id
5836 assert "dimensions" in data
5837 assert len(data["dimensions"]) == 5
5838 dim_names = {d["dimension"] for d in data["dimensions"]}
5839 assert dim_names == {"harmonic", "rhythmic", "melodic", "structural", "dynamic"}
5840 for dim in data["dimensions"]:
5841 assert 0.0 <= dim["score"] <= 1.0
5842 assert dim["label"] in {"none", "low", "medium", "high"}
5843 assert dim["color"] in {"dim-none", "dim-low", "dim-medium", "dim-high"}
5844 assert "overallScore" in data
5845 assert 0.0 <= data["overallScore"] <= 1.0
5846
5847
5848 @pytest.mark.anyio
5849 async def test_commit_detail_diff_summary_root_commit_scores_one(
5850 client: AsyncClient,
5851 db_session: AsyncSession,
5852 auth_headers: dict[str, str],
5853 ) -> None:
5854 """Diff summary for a root commit (no parent) scores all dimensions at 1.0."""
5855 repo_id, parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5856 response = await client.get(
5857 f"/api/v1/repos/{repo_id}/commits/{parent_id}/diff-summary",
5858 headers=auth_headers,
5859 )
5860 assert response.status_code == 200
5861 data = response.json()
5862 assert data["parentId"] is None
5863 for dim in data["dimensions"]:
5864 assert dim["score"] == 1.0
5865 assert dim["label"] == "high"
5866
5867
5868 @pytest.mark.anyio
5869 async def test_commit_detail_diff_summary_keyword_detection(
5870 client: AsyncClient,
5871 db_session: AsyncSession,
5872 auth_headers: dict[str, str],
5873 ) -> None:
5874 """Diff summary detects melodic keyword in child commit message."""
5875 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5876 response = await client.get(
5877 f"/api/v1/repos/{repo_id}/commits/{child_id}/diff-summary",
5878 headers=auth_headers,
5879 )
5880 assert response.status_code == 200
5881 data = response.json()
5882 melodic_dim = next(d for d in data["dimensions"] if d["dimension"] == "melodic")
5883 # child commit message contains "melodic" keyword → non-zero score
5884 assert melodic_dim["score"] > 0.0
5885
5886
5887 @pytest.mark.anyio
5888 async def test_commit_detail_diff_summary_unknown_commit_404(
5889 client: AsyncClient,
5890 db_session: AsyncSession,
5891 auth_headers: dict[str, str],
5892 ) -> None:
5893 """Diff summary for unknown commit ID returns 404."""
5894 repo_id, _p, _c = await _seed_commit_detail_fixtures(db_session)
5895 response = await client.get(
5896 f"/api/v1/repos/{repo_id}/commits/deadbeefdeadbeefdeadbeef/diff-summary",
5897 headers=auth_headers, )
5898 assert response.status_code == 404
5899
5900
5901 # ---------------------------------------------------------------------------
5902 # Commit comment threads — # ---------------------------------------------------------------------------
5903
5904
5905 @pytest.mark.anyio
5906 async def test_commit_page_has_comment_section_html(
5907 client: AsyncClient,
5908 db_session: AsyncSession,
5909 ) -> None:
5910 """Commit detail page HTML includes the HTMX comment target container."""
5911 from datetime import datetime, timezone
5912
5913 repo_id = await _make_repo(db_session)
5914 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5915 commit = MusehubCommit(
5916 commit_id=commit_id,
5917 repo_id=repo_id,
5918 branch="main",
5919 parent_ids=[],
5920 message="Add chorus section",
5921 author="testuser",
5922 timestamp=datetime.now(tz=timezone.utc),
5923 )
5924 db_session.add(commit)
5925 await db_session.commit()
5926
5927 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
5928 assert response.status_code == 200
5929 body = response.text
5930 # SSR replaces JS-loaded comment section with a server-rendered HTMX target
5931 assert "commit-comments" in body
5932 assert "hx-target" in body
5933
5934
5935 @pytest.mark.anyio
5936 async def test_commit_page_has_htmx_comment_form(
5937 client: AsyncClient,
5938 db_session: AsyncSession,
5939 ) -> None:
5940 """Commit detail page has an HTMX-driven comment form (replaces old JS comment functions)."""
5941 from datetime import datetime, timezone
5942
5943 repo_id = await _make_repo(db_session)
5944 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5945 commit = MusehubCommit(
5946 commit_id=commit_id,
5947 repo_id=repo_id,
5948 branch="main",
5949 parent_ids=[],
5950 message="Add chorus section",
5951 author="testuser",
5952 timestamp=datetime.now(tz=timezone.utc),
5953 )
5954 db_session.add(commit)
5955 await db_session.commit()
5956
5957 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
5958 assert response.status_code == 200
5959 body = response.text
5960 # HTMX form replaces JS renderComments/submitComment/loadComments
5961 assert "hx-post" in body
5962 assert "hx-target" in body
5963 assert "textarea" in body
5964
5965
5966 @pytest.mark.anyio
5967 async def test_commit_page_comment_htmx_target_present(
5968 client: AsyncClient,
5969 db_session: AsyncSession,
5970 ) -> None:
5971 """HTMX comment target div is present for server-side comment injection."""
5972 from datetime import datetime, timezone
5973
5974 repo_id = await _make_repo(db_session)
5975 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5976 commit = MusehubCommit(
5977 commit_id=commit_id,
5978 repo_id=repo_id,
5979 branch="main",
5980 parent_ids=[],
5981 message="Add chorus section",
5982 author="testuser",
5983 timestamp=datetime.now(tz=timezone.utc),
5984 )
5985 db_session.add(commit)
5986 await db_session.commit()
5987
5988 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
5989 assert response.status_code == 200
5990 body = response.text
5991 assert 'id="commit-comments"' in body
5992
5993
5994 @pytest.mark.anyio
5995 async def test_commit_page_comment_htmx_posts_to_comments_endpoint(
5996 client: AsyncClient,
5997 db_session: AsyncSession,
5998 ) -> None:
5999 """HTMX form posts to the commit comments endpoint (replaces old JS API fetch)."""
6000 from datetime import datetime, timezone
6001
6002 repo_id = await _make_repo(db_session)
6003 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6004 commit = MusehubCommit(
6005 commit_id=commit_id,
6006 repo_id=repo_id,
6007 branch="main",
6008 parent_ids=[],
6009 message="Add chorus section",
6010 author="testuser",
6011 timestamp=datetime.now(tz=timezone.utc),
6012 )
6013 db_session.add(commit)
6014 await db_session.commit()
6015
6016 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6017 assert response.status_code == 200
6018 body = response.text
6019 assert "hx-post" in body
6020 assert "/comments" in body
6021
6022
6023 @pytest.mark.anyio
6024 async def test_commit_page_comment_has_ssr_avatar(
6025 client: AsyncClient,
6026 db_session: AsyncSession,
6027 ) -> None:
6028 """Commit page SSR comment thread renders avatar initials via server-side template."""
6029 from datetime import datetime, timezone
6030
6031 repo_id = await _make_repo(db_session)
6032 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6033 commit = MusehubCommit(
6034 commit_id=commit_id,
6035 repo_id=repo_id,
6036 branch="main",
6037 parent_ids=[],
6038 message="Add chorus section",
6039 author="testuser",
6040 timestamp=datetime.now(tz=timezone.utc),
6041 )
6042 db_session.add(commit)
6043 await db_session.commit()
6044
6045 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6046 assert response.status_code == 200
6047 body = response.text
6048 # comment-avatar only rendered when comments exist; check commit page structure
6049 assert "commit-detail" in body or "page-data" in body
6050
6051
6052 @pytest.mark.anyio
6053 async def test_commit_page_comment_has_htmx_form_elements(
6054 client: AsyncClient,
6055 db_session: AsyncSession,
6056 ) -> None:
6057 """Commit page HTMX comment form has textarea and submit button."""
6058 from datetime import datetime, timezone
6059
6060 repo_id = await _make_repo(db_session)
6061 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6062 commit = MusehubCommit(
6063 commit_id=commit_id,
6064 repo_id=repo_id,
6065 branch="main",
6066 parent_ids=[],
6067 message="Add chorus section",
6068 author="testuser",
6069 timestamp=datetime.now(tz=timezone.utc),
6070 )
6071 db_session.add(commit)
6072 await db_session.commit()
6073
6074 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6075 assert response.status_code == 200
6076 body = response.text
6077 # HTMX form replaces old new-comment-form/new-comment-body/comment-submit-btn
6078 assert 'name="body"' in body
6079 assert "btn-primary" in body
6080 assert "Comment" in body
6081
6082
6083 @pytest.mark.anyio
6084 async def test_commit_page_comment_section_shows_count_heading(
6085 client: AsyncClient,
6086 db_session: AsyncSession,
6087 ) -> None:
6088 """Commit page SSR comment section shows a count heading (replaces 'Discussion' heading)."""
6089 from datetime import datetime, timezone
6090
6091 repo_id = await _make_repo(db_session)
6092 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6093 commit = MusehubCommit(
6094 commit_id=commit_id,
6095 repo_id=repo_id,
6096 branch="main",
6097 parent_ids=[],
6098 message="Add chorus section",
6099 author="testuser",
6100 timestamp=datetime.now(tz=timezone.utc),
6101 )
6102 db_session.add(commit)
6103 await db_session.commit()
6104
6105 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6106 assert response.status_code == 200
6107 body = response.text
6108 assert "comment" in body
6109
6110
6111 # ---------------------------------------------------------------------------
6112 # Commit detail enhancements — ref URL links, DB tags in panel, prose
6113 # summary
6114 # ---------------------------------------------------------------------------
6115
6116
6117 @pytest.mark.anyio
6118 async def test_commit_page_ssr_renders_commit_message(
6119 client: AsyncClient,
6120 db_session: AsyncSession,
6121 ) -> None:
6122 """Commit message is rendered server-side (replaces JS ref-tag / tagPill rendering)."""
6123 from datetime import datetime, timezone
6124
6125 repo_id = await _make_repo(db_session)
6126 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6127 commit = MusehubCommit(
6128 commit_id=commit_id,
6129 repo_id=repo_id,
6130 branch="main",
6131 parent_ids=[],
6132 message="Unique groove message XYZ",
6133 author="testuser",
6134 timestamp=datetime.now(tz=timezone.utc),
6135 )
6136 db_session.add(commit)
6137 await db_session.commit()
6138
6139 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6140 assert response.status_code == 200
6141 body = response.text
6142 # SSR renders commit message directly — no JS tagPill/isRefUrl needed
6143 assert "Unique groove message XYZ" in body
6144
6145
6146 @pytest.mark.anyio
6147 async def test_commit_page_ssr_renders_author_metadata(
6148 client: AsyncClient,
6149 db_session: AsyncSession,
6150 ) -> None:
6151 """Commit author and branch appear in the SSR metadata grid (replaces JS muse-tags panel)."""
6152 from datetime import datetime, timezone
6153
6154 repo_id = await _make_repo(db_session)
6155 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6156 commit = MusehubCommit(
6157 commit_id=commit_id,
6158 repo_id=repo_id,
6159 branch="main",
6160 parent_ids=[],
6161 message="Add chorus section",
6162 author="jazzproducer",
6163 timestamp=datetime.now(tz=timezone.utc),
6164 )
6165 db_session.add(commit)
6166 await db_session.commit()
6167
6168 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6169 assert response.status_code == 200
6170 body = response.text
6171 # SSR metadata grid shows author — no JS loadMuseTagsPanel needed
6172 assert "jazzproducer" in body
6173
6174
6175 @pytest.mark.anyio
6176 async def test_commit_page_no_audio_shell_when_no_snapshot(
6177 client: AsyncClient,
6178 db_session: AsyncSession,
6179 ) -> None:
6180 """Commit page without snapshot_id omits WaveSurfer shell (replaces buildProseSummary check)."""
6181 from datetime import datetime, timezone
6182
6183 repo_id = await _make_repo(db_session)
6184 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6185 commit = MusehubCommit(
6186 commit_id=commit_id,
6187 repo_id=repo_id,
6188 branch="main",
6189 parent_ids=[],
6190 message="Add chorus section",
6191 author="testuser",
6192 timestamp=datetime.now(tz=timezone.utc),
6193 snapshot_id=None,
6194 )
6195 db_session.add(commit)
6196 await db_session.commit()
6197
6198 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6199 assert response.status_code == 200
6200 body = response.text
6201 assert "commit-waveform" not in body
6202
6203
6204 # ---------------------------------------------------------------------------
6205 # Audio player — listen page tests
6206 # ---------------------------------------------------------------------------
6207
6208
6209 @pytest.mark.anyio
6210 async def test_listen_page_renders(
6211 client: AsyncClient,
6212 db_session: AsyncSession,
6213 ) -> None:
6214 """GET /{owner}/{slug}/listen/{ref} must return 200 HTML."""
6215 await _make_repo(db_session)
6216 ref = "abc1234567890abcdef"
6217 response = await client.get(f"/testuser/test-beats/listen/{ref}")
6218 assert response.status_code == 200
6219 assert "text/html" in response.headers["content-type"]
6220
6221
6222 @pytest.mark.anyio
6223 async def test_listen_page_no_auth_required(
6224 client: AsyncClient,
6225 db_session: AsyncSession,
6226 ) -> None:
6227 """Listen page must be accessible without an Authorization header."""
6228 await _make_repo(db_session)
6229 ref = "deadbeef1234"
6230 response = await client.get(f"/testuser/test-beats/listen/{ref}")
6231 assert response.status_code != 401
6232 assert response.status_code == 200
6233
6234
6235 @pytest.mark.anyio
6236 async def test_listen_page_contains_waveform_ui(
6237 client: AsyncClient,
6238 db_session: AsyncSession,
6239 ) -> None:
6240 """Listen page HTML must contain the waveform container element."""
6241 await _make_repo(db_session)
6242 ref = "cafebabe1234"
6243 response = await client.get(f"/testuser/test-beats/listen/{ref}")
6244 assert response.status_code == 200
6245 body = response.text
6246 assert "waveform" in body
6247
6248
6249 @pytest.mark.anyio
6250 async def test_listen_page_contains_play_button(
6251 client: AsyncClient,
6252 db_session: AsyncSession,
6253 ) -> None:
6254 """Listen page must include a play button element."""
6255 await _make_repo(db_session)
6256 ref = "feed1234abcdef"
6257 response = await client.get(f"/testuser/test-beats/listen/{ref}")
6258 assert response.status_code == 200
6259 body = response.text
6260 assert "play-btn" in body
6261
6262
6263 @pytest.mark.anyio
6264 async def test_listen_page_contains_speed_selector(
6265 client: AsyncClient,
6266 db_session: AsyncSession,
6267 ) -> None:
6268 """Listen page must include the playback speed selector element."""
6269 await _make_repo(db_session)
6270 ref = "1a2b3c4d5e6f7890"
6271 response = await client.get(f"/testuser/test-beats/listen/{ref}")
6272 assert response.status_code == 200
6273 body = response.text
6274 assert "speed-sel" in body
6275
6276
6277 @pytest.mark.anyio
6278 async def test_listen_page_contains_ab_loop_ui(
6279 client: AsyncClient,
6280 db_session: AsyncSession,
6281 ) -> None:
6282 """Listen page must include A/B loop controls (loop info + clear button)."""
6283 await _make_repo(db_session)
6284 ref = "aabbccddeeff0011"
6285 response = await client.get(f"/testuser/test-beats/listen/{ref}")
6286 assert response.status_code == 200
6287 body = response.text
6288 assert "loop-info" in body
6289 assert "loop-clear-btn" in body
6290
6291
6292 @pytest.mark.anyio
6293 async def test_listen_page_loads_wavesurfer_vendor(
6294 client: AsyncClient,
6295 db_session: AsyncSession,
6296 ) -> None:
6297 """Listen page must load wavesurfer from the local vendor path, not from a CDN."""
6298 await _make_repo(db_session)
6299 ref = "112233445566778899"
6300 response = await client.get(f"/testuser/test-beats/listen/{ref}")
6301 assert response.status_code == 200
6302 body = response.text
6303 # wavesurfer must be loaded from the local vendor directory
6304 assert "vendor/wavesurfer.min.js" in body
6305 # wavesurfer must NOT be loaded from an external CDN
6306 assert "unpkg.com/wavesurfer" not in body
6307 assert "cdn.jsdelivr.net/wavesurfer" not in body
6308 assert "cdnjs.cloudflare.com/ajax/libs/wavesurfer" not in body
6309
6310
6311 @pytest.mark.anyio
6312 async def test_listen_page_loads_audio_player_js(
6313 client: AsyncClient,
6314 db_session: AsyncSession,
6315 ) -> None:
6316 """Listen page must load the audio-player.js component wrapper script."""
6317 await _make_repo(db_session)
6318 ref = "99aabbccddeeff00"
6319 response = await client.get(f"/testuser/test-beats/listen/{ref}")
6320 assert response.status_code == 200
6321 body = response.text
6322 assert "audio-player.js" in body
6323
6324
6325 @pytest.mark.anyio
6326 async def test_listen_track_page_renders(
6327 client: AsyncClient,
6328 db_session: AsyncSession,
6329 ) -> None:
6330 """GET /{owner}/{slug}/listen/{ref}/{path} must return 200."""
6331 await _make_repo(db_session)
6332 ref = "feedface0011aabb"
6333 response = await client.get(
6334 f"/testuser/test-beats/listen/{ref}/tracks/bass.mp3"
6335 )
6336 assert response.status_code == 200
6337 assert "text/html" in response.headers["content-type"]
6338
6339
6340 @pytest.mark.anyio
6341 async def test_listen_track_page_has_track_path_in_js(
6342 client: AsyncClient,
6343 db_session: AsyncSession,
6344 ) -> None:
6345 """Track path must be injected into the page JS context as TRACK_PATH."""
6346 await _make_repo(db_session)
6347 ref = "00aabbccddeeff11"
6348 track = "tracks/lead-guitar.mp3"
6349 response = await client.get(
6350 f"/testuser/test-beats/listen/{ref}/{track}"
6351 )
6352 assert response.status_code == 200
6353 body = response.text
6354 assert "TRACK_PATH" in body
6355 assert "lead-guitar.mp3" in body
6356
6357
6358 @pytest.mark.anyio
6359 async def test_listen_page_unknown_repo_404(
6360 client: AsyncClient,
6361 db_session: AsyncSession,
6362 ) -> None:
6363 """GET listen page with nonexistent owner/slug must return 404."""
6364 response = await client.get(
6365 "/nobody/nonexistent-repo/listen/abc123"
6366 )
6367 assert response.status_code == 404
6368
6369
6370 @pytest.mark.anyio
6371 async def test_listen_page_keyboard_shortcuts_documented(
6372 client: AsyncClient,
6373 db_session: AsyncSession,
6374 ) -> None:
6375 """Listen page must document Space, arrow, and L keyboard shortcuts."""
6376 await _make_repo(db_session)
6377 ref = "cafe0011aabb2233"
6378 response = await client.get(f"/testuser/test-beats/listen/{ref}")
6379 assert response.status_code == 200
6380 body = response.text
6381 # Keyboard hint section must be present
6382 assert "Space" in body or "space" in body.lower()
6383 assert "loop" in body.lower()
6384
6385
6386 # ---------------------------------------------------------------------------
6387 # Compare view
6388 # ---------------------------------------------------------------------------
6389
6390
6391 @pytest.mark.anyio
6392 async def test_compare_page_renders(
6393 client: AsyncClient,
6394 db_session: AsyncSession,
6395 ) -> None:
6396 """GET /{owner}/{slug}/compare/{base}...{head} returns 200 HTML."""
6397 await _make_repo(db_session)
6398 response = await client.get("/testuser/test-beats/compare/main...feature")
6399 assert response.status_code == 200
6400 assert "text/html" in response.headers["content-type"]
6401 body = response.text
6402 assert "MuseHub" in body
6403 assert "main" in body
6404 assert "feature" in body
6405
6406
6407 @pytest.mark.anyio
6408 async def test_compare_page_no_auth_required(
6409 client: AsyncClient,
6410 db_session: AsyncSession,
6411 ) -> None:
6412 """Compare page is accessible without a JWT token."""
6413 await _make_repo(db_session)
6414 response = await client.get("/testuser/test-beats/compare/main...feature")
6415 assert response.status_code == 200
6416
6417
6418 @pytest.mark.anyio
6419 async def test_compare_page_invalid_ref_404(
6420 client: AsyncClient,
6421 db_session: AsyncSession,
6422 ) -> None:
6423 """Compare path without '...' separator returns 404."""
6424 await _make_repo(db_session)
6425 response = await client.get("/testuser/test-beats/compare/mainfeature")
6426 assert response.status_code == 404
6427
6428
6429 @pytest.mark.anyio
6430 async def test_compare_page_unknown_owner_404(
6431 client: AsyncClient,
6432 ) -> None:
6433 """Unknown owner/slug combination returns 404 on compare page."""
6434 response = await client.get("/nobody/norepo/compare/main...feature")
6435 assert response.status_code == 404
6436
6437
6438 @pytest.mark.anyio
6439 async def test_compare_page_includes_radar(
6440 client: AsyncClient,
6441 db_session: AsyncSession,
6442 ) -> None:
6443 """Compare page SSR HTML contains all five musical dimension names (replaces JS radar).
6444
6445 The compare page now renders data server-side via a dimension table.
6446 Musical dimensions (Melodic, Harmonic, etc.) must appear in the HTML body
6447 before any client-side JavaScript runs.
6448 """
6449 await _make_repo(db_session)
6450 response = await client.get("/testuser/test-beats/compare/main...feature")
6451 assert response.status_code == 200
6452 body = response.text
6453 assert "Melodic" in body
6454 assert "Harmonic" in body
6455
6456
6457 @pytest.mark.anyio
6458 async def test_compare_page_includes_piano_roll(
6459 client: AsyncClient,
6460 db_session: AsyncSession,
6461 ) -> None:
6462 """Compare page SSR HTML contains the dimension table (replaces piano roll JS panel).
6463
6464 The compare page now renders a dimension comparison table server-side.
6465 Both ref names must appear as column headers in the HTML.
6466 """
6467 await _make_repo(db_session)
6468 response = await client.get("/testuser/test-beats/compare/main...feature")
6469 assert response.status_code == 200
6470 body = response.text
6471 assert "main" in body
6472 assert "feature" in body
6473 assert "Dimension" in body
6474
6475
6476 @pytest.mark.anyio
6477 async def test_compare_page_includes_emotion_diff(
6478 client: AsyncClient,
6479 db_session: AsyncSession,
6480 ) -> None:
6481 """Compare page SSR HTML contains change delta column (replaces emotion diff JS).
6482
6483 The dimension table includes a Change column showing delta values server-side.
6484 """
6485 await _make_repo(db_session)
6486 response = await client.get("/testuser/test-beats/compare/main...feature")
6487 assert response.status_code == 200
6488 body = response.text
6489 assert "Change" in body
6490 assert "%" in body
6491
6492
6493 @pytest.mark.anyio
6494 async def test_compare_page_includes_commit_list(
6495 client: AsyncClient,
6496 db_session: AsyncSession,
6497 ) -> None:
6498 """Compare page SSR HTML contains dimension rows (replaces client-side commit list JS).
6499
6500 All five musical dimensions must appear as data rows in the server-rendered table.
6501 """
6502 await _make_repo(db_session)
6503 response = await client.get("/testuser/test-beats/compare/main...feature")
6504 assert response.status_code == 200
6505 body = response.text
6506 assert "Rhythmic" in body
6507 assert "Structural" in body
6508 assert "Dynamic" in body
6509
6510
6511 @pytest.mark.anyio
6512 async def test_compare_page_includes_create_pr_button(
6513 client: AsyncClient,
6514 db_session: AsyncSession,
6515 ) -> None:
6516 """Compare page SSR HTML contains both ref names in the heading (replaces PR button CTA).
6517
6518 The SSR compare page shows the base and head refs in the page header.
6519 """
6520 await _make_repo(db_session)
6521 response = await client.get("/testuser/test-beats/compare/main...feature")
6522 assert response.status_code == 200
6523 body = response.text
6524 assert "Compare" in body
6525 assert "main" in body
6526 assert "feature" in body
6527
6528
6529 @pytest.mark.anyio
6530 async def test_compare_json_response(
6531 client: AsyncClient,
6532 db_session: AsyncSession,
6533 ) -> None:
6534 """GET /{owner}/{slug}/compare/{refs} returns HTML with SSR dimension data.
6535
6536 The compare page is now fully SSR — no JSON format negotiation.
6537 The response is always text/html containing the dimension table.
6538 """
6539 await _make_repo(db_session)
6540 response = await client.get("/testuser/test-beats/compare/main...feature")
6541 assert response.status_code == 200
6542 assert "text/html" in response.headers["content-type"]
6543 body = response.text
6544 assert "Melodic" in body
6545 assert "main" in body
6546
6547
6548 # ---------------------------------------------------------------------------
6549 # Issue #208 — Branch list and tag browser tests
6550 # ---------------------------------------------------------------------------
6551
6552
6553 async def _make_repo_with_branches(
6554 db_session: AsyncSession,
6555 ) -> tuple[str, str, str]:
6556 """Seed a repo with two branches (main + feature) and return (repo_id, owner, slug)."""
6557 repo = MusehubRepo(
6558 name="branch-test",
6559 owner="testuser",
6560 slug="branch-test",
6561 visibility="private",
6562 owner_user_id="test-owner",
6563 )
6564 db_session.add(repo)
6565 await db_session.flush()
6566 repo_id = str(repo.repo_id)
6567
6568 main_branch = MusehubBranch(repo_id=repo_id, name="main", head_commit_id="aaa000")
6569 feat_branch = MusehubBranch(repo_id=repo_id, name="feat/jazz-bridge", head_commit_id="bbb111")
6570 db_session.add_all([main_branch, feat_branch])
6571
6572 # Two commits on main, one unique commit on feat/jazz-bridge
6573 now = datetime.now(UTC)
6574 c1 = MusehubCommit(
6575 commit_id="aaa000",
6576 repo_id=repo_id,
6577 branch="main",
6578 parent_ids=[],
6579 message="Initial commit",
6580 author="composer@muse.app",
6581 timestamp=now,
6582 )
6583 c2 = MusehubCommit(
6584 commit_id="aaa001",
6585 repo_id=repo_id,
6586 branch="main",
6587 parent_ids=["aaa000"],
6588 message="Add bridge",
6589 author="composer@muse.app",
6590 timestamp=now,
6591 )
6592 c3 = MusehubCommit(
6593 commit_id="bbb111",
6594 repo_id=repo_id,
6595 branch="feat/jazz-bridge",
6596 parent_ids=["aaa000"],
6597 message="Add jazz chord",
6598 author="composer@muse.app",
6599 timestamp=now,
6600 )
6601 db_session.add_all([c1, c2, c3])
6602 await db_session.commit()
6603 return repo_id, "testuser", "branch-test"
6604
6605
6606 async def _make_repo_with_releases(
6607 db_session: AsyncSession,
6608 ) -> tuple[str, str, str]:
6609 """Seed a repo with namespaced releases used as tags."""
6610 repo = MusehubRepo(
6611 name="tag-test",
6612 owner="testuser",
6613 slug="tag-test",
6614 visibility="private",
6615 owner_user_id="test-owner",
6616 )
6617 db_session.add(repo)
6618 await db_session.flush()
6619 repo_id = str(repo.repo_id)
6620
6621 now = datetime.now(UTC)
6622 releases = [
6623 MusehubRelease(
6624 repo_id=repo_id, tag="emotion:happy", title="Happy vibes", body="",
6625 commit_id="abc001", author="composer", created_at=now, download_urls={},
6626 ),
6627 MusehubRelease(
6628 repo_id=repo_id, tag="genre:jazz", title="Jazz release", body="",
6629 commit_id="abc002", author="composer", created_at=now, download_urls={},
6630 ),
6631 MusehubRelease(
6632 repo_id=repo_id, tag="v1.0", title="Version 1.0", body="",
6633 commit_id="abc003", author="composer", created_at=now, download_urls={},
6634 ),
6635 ]
6636 db_session.add_all(releases)
6637 await db_session.commit()
6638 return repo_id, "testuser", "tag-test"
6639
6640
6641 @pytest.mark.anyio
6642 async def test_branches_page_lists_all(
6643 client: AsyncClient,
6644 db_session: AsyncSession,
6645 ) -> None:
6646 """GET /{owner}/{slug}/branches returns 200 HTML."""
6647 await _make_repo_with_branches(db_session)
6648 resp = await client.get("/testuser/branch-test/branches")
6649 assert resp.status_code == 200
6650 assert "text/html" in resp.headers["content-type"]
6651 body = resp.text
6652 assert "MuseHub" in body
6653 # Page-specific JS identifiers
6654 assert "branch-row" in body or "branches" in body.lower()
6655
6656
6657 @pytest.mark.anyio
6658 async def test_branches_default_marked(
6659 client: AsyncClient,
6660 db_session: AsyncSession,
6661 ) -> None:
6662 """JSON response marks the default branch with isDefault=true."""
6663 await _make_repo_with_branches(db_session)
6664 resp = await client.get(
6665 "/testuser/branch-test/branches",
6666 headers={"Accept": "application/json"},
6667 )
6668 assert resp.status_code == 200
6669 data = resp.json()
6670 assert "branches" in data
6671 default_branches = [b for b in data["branches"] if b.get("isDefault")]
6672 assert len(default_branches) == 1
6673 assert default_branches[0]["name"] == "main"
6674
6675
6676 @pytest.mark.anyio
6677 async def test_branches_compare_link(
6678 client: AsyncClient,
6679 db_session: AsyncSession,
6680 ) -> None:
6681 """Branches page HTML contains compare link JavaScript."""
6682 await _make_repo_with_branches(db_session)
6683 resp = await client.get("/testuser/branch-test/branches")
6684 assert resp.status_code == 200
6685 body = resp.text
6686 # The JS template must reference the compare URL pattern
6687 assert "compare" in body.lower()
6688
6689
6690 @pytest.mark.anyio
6691 async def test_branches_new_pr_button(
6692 client: AsyncClient,
6693 db_session: AsyncSession,
6694 ) -> None:
6695 """Branches page HTML contains New Pull Request link JavaScript."""
6696 await _make_repo_with_branches(db_session)
6697 resp = await client.get("/testuser/branch-test/branches")
6698 assert resp.status_code == 200
6699 body = resp.text
6700 assert "Pull Request" in body
6701
6702
6703 @pytest.mark.anyio
6704 async def test_branches_json_response(
6705 client: AsyncClient,
6706 db_session: AsyncSession,
6707 ) -> None:
6708 """JSON response includes branches with ahead/behind counts and divergence placeholder."""
6709 await _make_repo_with_branches(db_session)
6710 resp = await client.get(
6711 "/testuser/branch-test/branches?format=json",
6712 )
6713 assert resp.status_code == 200
6714 data = resp.json()
6715 assert "branches" in data
6716 assert "defaultBranch" in data
6717 assert data["defaultBranch"] == "main"
6718
6719 branches_by_name = {b["name"]: b for b in data["branches"]}
6720 assert "main" in branches_by_name
6721 assert "feat/jazz-bridge" in branches_by_name
6722
6723 main = branches_by_name["main"]
6724 assert main["isDefault"] is True
6725 assert main["aheadCount"] == 0
6726 assert main["behindCount"] == 0
6727
6728 feat = branches_by_name["feat/jazz-bridge"]
6729 assert feat["isDefault"] is False
6730 # feat has 1 unique commit (bbb111); main has 2 commits (aaa000, aaa001) not shared with feat
6731 assert feat["aheadCount"] == 1
6732 assert feat["behindCount"] == 2
6733
6734 # Divergence is a placeholder (all None)
6735 div = feat["divergence"]
6736 assert div["melodic"] is None
6737 assert div["harmonic"] is None
6738
6739
6740 @pytest.mark.anyio
6741 async def test_tags_page_lists_all(
6742 client: AsyncClient,
6743 db_session: AsyncSession,
6744 ) -> None:
6745 """GET /{owner}/{slug}/tags returns 200 HTML."""
6746 await _make_repo_with_releases(db_session)
6747 resp = await client.get("/testuser/tag-test/tags")
6748 assert resp.status_code == 200
6749 assert "text/html" in resp.headers["content-type"]
6750 body = resp.text
6751 assert "MuseHub" in body
6752 assert "Tags" in body
6753
6754
6755 @pytest.mark.anyio
6756 async def test_tags_namespace_filter(
6757 client: AsyncClient,
6758 db_session: AsyncSession,
6759 ) -> None:
6760 """Tags page HTML includes namespace filter dropdown JavaScript."""
6761 await _make_repo_with_releases(db_session)
6762 resp = await client.get("/testuser/tag-test/tags")
6763 assert resp.status_code == 200
6764 body = resp.text
6765 # Namespace filter select element is rendered by JS
6766 assert "ns-filter" in body or "namespace" in body.lower()
6767 # Namespace icons present
6768 assert "&#127768;" in body or "emotion" in body
6769
6770
6771 @pytest.mark.anyio
6772 async def test_tags_json_response(
6773 client: AsyncClient,
6774 db_session: AsyncSession,
6775 ) -> None:
6776 """JSON response returns TagListResponse with namespace grouping."""
6777 await _make_repo_with_releases(db_session)
6778 resp = await client.get(
6779 "/testuser/tag-test/tags?format=json",
6780 )
6781 assert resp.status_code == 200
6782 data = resp.json()
6783 assert "tags" in data
6784 assert "namespaces" in data
6785
6786 # All three releases become tags
6787 assert len(data["tags"]) == 3
6788
6789 tags_by_name = {t["tag"]: t for t in data["tags"]}
6790 assert "emotion:happy" in tags_by_name
6791 assert "genre:jazz" in tags_by_name
6792 assert "v1.0" in tags_by_name
6793
6794 assert tags_by_name["emotion:happy"]["namespace"] == "emotion"
6795 assert tags_by_name["genre:jazz"]["namespace"] == "genre"
6796 assert tags_by_name["v1.0"]["namespace"] == "version"
6797
6798 # Namespaces are sorted
6799 assert sorted(data["namespaces"]) == data["namespaces"]
6800 assert "emotion" in data["namespaces"]
6801 assert "genre" in data["namespaces"]
6802 assert "version" in data["namespaces"]
6803
6804
6805
6806 # ---------------------------------------------------------------------------
6807 # Arrangement matrix page — # ---------------------------------------------------------------------------
6808
6809
6810 # ---------------------------------------------------------------------------
6811 # Piano roll page tests — # ---------------------------------------------------------------------------
6812
6813
6814 @pytest.mark.anyio
6815 async def test_arrange_page_returns_200(
6816 client: AsyncClient,
6817 db_session: AsyncSession,
6818 ) -> None:
6819 """GET /{owner}/{slug}/arrange/{ref} returns 200 HTML without a JWT."""
6820 await _make_repo(db_session)
6821 response = await client.get("/testuser/test-beats/arrange/HEAD")
6822 assert response.status_code == 200
6823 assert "text/html" in response.headers["content-type"]
6824
6825
6826 @pytest.mark.anyio
6827 async def test_piano_roll_page_returns_200(
6828 client: AsyncClient,
6829 db_session: AsyncSession,
6830 ) -> None:
6831 """GET /{owner}/{slug}/piano-roll/{ref} returns 200 HTML."""
6832 await _make_repo(db_session)
6833 response = await client.get("/testuser/test-beats/piano-roll/main")
6834 assert response.status_code == 200
6835 assert "text/html" in response.headers["content-type"]
6836
6837
6838 @pytest.mark.anyio
6839 async def test_arrange_page_no_auth_required(
6840 client: AsyncClient,
6841 db_session: AsyncSession,
6842 ) -> None:
6843 """Arrangement matrix page is accessible without a JWT (auth handled client-side)."""
6844 await _make_repo(db_session)
6845 response = await client.get("/testuser/test-beats/arrange/HEAD")
6846 assert response.status_code == 200
6847 assert response.status_code != 401
6848
6849
6850 @pytest.mark.anyio
6851 async def test_arrange_page_contains_musehub(
6852 client: AsyncClient,
6853 db_session: AsyncSession,
6854 ) -> None:
6855 """Arrangement matrix page HTML shell contains 'MuseHub' branding."""
6856 await _make_repo(db_session)
6857 response = await client.get("/testuser/test-beats/arrange/abc1234")
6858 assert response.status_code == 200
6859 assert "MuseHub" in response.text
6860
6861
6862 @pytest.mark.anyio
6863 async def test_arrange_page_contains_grid_js(
6864 client: AsyncClient,
6865 db_session: AsyncSession,
6866 ) -> None:
6867 """Arrangement matrix page embeds the grid rendering JS (renderMatrix or arrange)."""
6868 await _make_repo(db_session)
6869 response = await client.get("/testuser/test-beats/arrange/HEAD")
6870 assert response.status_code == 200
6871 body = response.text
6872 assert "renderMatrix" in body or "arrange" in body.lower()
6873
6874
6875 @pytest.mark.anyio
6876 async def test_arrange_page_contains_density_logic(
6877 client: AsyncClient,
6878 db_session: AsyncSession,
6879 ) -> None:
6880 """Arrangement matrix page includes density colour logic."""
6881 await _make_repo(db_session)
6882 response = await client.get("/testuser/test-beats/arrange/HEAD")
6883 assert response.status_code == 200
6884 body = response.text
6885 assert "density" in body.lower() or "noteDensity" in body
6886
6887
6888 @pytest.mark.anyio
6889 async def test_arrange_page_contains_token_form(
6890 client: AsyncClient,
6891 db_session: AsyncSession,
6892 ) -> None:
6893 """Arrangement matrix page renders the SSR arrange grid."""
6894 await _make_repo(db_session)
6895 response = await client.get("/testuser/test-beats/arrange/HEAD")
6896 assert response.status_code == 200
6897 body = response.text
6898 assert "arrange-wrap" in body or "arrange-table" in body
6899 assert "Arrange" in body
6900
6901
6902 @pytest.mark.anyio
6903 async def test_arrange_page_unknown_repo_returns_404(
6904 client: AsyncClient,
6905 db_session: AsyncSession,
6906 ) -> None:
6907 """GET /{unknown}/{slug}/arrange/{ref} returns 404 for unknown repos."""
6908 response = await client.get("/unknown-user/no-such-repo/arrange/HEAD")
6909 assert response.status_code == 404
6910
6911
6912 @pytest.mark.anyio
6913 async def test_commit_detail_unknown_format_param_returns_html(
6914 client: AsyncClient,
6915 db_session: AsyncSession,
6916 ) -> None:
6917 """GET commit detail page ignores ?format=json — SSR always returns HTML."""
6918 await _seed_commit_detail_fixtures(db_session)
6919 sha = "bbbb1111222233334444555566667777bbbbcccc"
6920 response = await client.get(
6921 f"/testuser/commit-detail-test/commits/{sha}?format=json"
6922 )
6923 assert response.status_code == 200
6924 assert "text/html" in response.headers["content-type"]
6925 # SSR commit page — commit message appears in body
6926 assert "feat(keys)" in response.text
6927
6928
6929 @pytest.mark.anyio
6930 async def test_commit_detail_wavesurfer_js_conditional_on_audio_url(
6931 client: AsyncClient,
6932 db_session: AsyncSession,
6933 ) -> None:
6934 """WaveSurfer JS block is only present when audio_url is set (replaces musicalMeta JS checks)."""
6935 await _seed_commit_detail_fixtures(db_session)
6936 sha = "bbbb1111222233334444555566667777bbbbcccc"
6937 response = await client.get(f"/testuser/commit-detail-test/commits/{sha}")
6938 assert response.status_code == 200
6939 body = response.text
6940 # The child commit has no snapshot_id in _seed_commit_detail_fixtures → no WaveSurfer
6941 assert "commit-waveform" not in body
6942 # WaveSurfer script only loaded when audio is present — not here
6943 assert "wavesurfer.min.js" not in body
6944
6945
6946 @pytest.mark.anyio
6947 async def test_commit_detail_nav_has_parent_link(
6948 client: AsyncClient,
6949 db_session: AsyncSession,
6950 ) -> None:
6951 """Commit detail page navigation includes the parent commit link (SSR)."""
6952 await _seed_commit_detail_fixtures(db_session)
6953 sha = "bbbb1111222233334444555566667777bbbbcccc"
6954 response = await client.get(f"/testuser/commit-detail-test/commits/{sha}")
6955 assert response.status_code == 200
6956 body = response.text
6957 # SSR renders parent commit link when parent_ids is non-empty
6958 assert "Parent Commit" in body
6959 # Parent SHA abbreviated to 8 chars in href
6960 assert "aaaa0000" in body
6961
6962
6963 @pytest.mark.anyio
6964 async def test_piano_roll_page_no_auth_required(
6965 client: AsyncClient,
6966 db_session: AsyncSession,
6967 ) -> None:
6968 """Piano roll UI page is accessible without a JWT token."""
6969 await _make_repo(db_session)
6970 response = await client.get("/testuser/test-beats/piano-roll/main")
6971 assert response.status_code == 200
6972
6973
6974 @pytest.mark.anyio
6975 async def test_piano_roll_page_loads_piano_roll_js(
6976 client: AsyncClient,
6977 db_session: AsyncSession,
6978 ) -> None:
6979 """Piano roll page references piano-roll.js script."""
6980 await _make_repo(db_session)
6981 response = await client.get("/testuser/test-beats/piano-roll/main")
6982 assert response.status_code == 200
6983 assert "piano-roll.js" in response.text
6984
6985
6986 @pytest.mark.anyio
6987 async def test_piano_roll_page_contains_canvas(
6988 client: AsyncClient,
6989 db_session: AsyncSession,
6990 ) -> None:
6991 """Piano roll page embeds a canvas element for rendering."""
6992 await _make_repo(db_session)
6993 response = await client.get("/testuser/test-beats/piano-roll/main")
6994 assert response.status_code == 200
6995 body = response.text
6996 assert "PianoRoll" in body or "piano-canvas" in body or "piano-roll.js" in body
6997
6998
6999 @pytest.mark.anyio
7000 async def test_piano_roll_page_has_token_form(
7001 client: AsyncClient,
7002 db_session: AsyncSession,
7003 ) -> None:
7004 """Piano roll page renders the SSR piano roll wrapper and canvas."""
7005 await _make_repo(db_session)
7006 response = await client.get("/testuser/test-beats/piano-roll/main")
7007 assert response.status_code == 200
7008 assert "piano-roll-wrapper" in response.text
7009 assert "piano-roll.js" in response.text
7010
7011
7012 @pytest.mark.anyio
7013 async def test_piano_roll_page_unknown_repo_404(
7014 client: AsyncClient,
7015 db_session: AsyncSession,
7016 ) -> None:
7017 """Piano roll page for an unknown repo returns 404."""
7018 response = await client.get("/nobody/no-repo/piano-roll/main")
7019 assert response.status_code == 404
7020
7021
7022 @pytest.mark.anyio
7023 async def test_arrange_tab_in_repo_nav(
7024 client: AsyncClient,
7025 db_session: AsyncSession,
7026 ) -> None:
7027 """Repo home page navigation includes an 'Arrange' tab link."""
7028 await _make_repo(db_session)
7029 response = await client.get("/testuser/test-beats")
7030 assert response.status_code == 200
7031 assert "Arrange" in response.text or "arrange" in response.text
7032
7033
7034 @pytest.mark.anyio
7035 async def test_piano_roll_track_page_returns_200(
7036 client: AsyncClient,
7037 db_session: AsyncSession,
7038 ) -> None:
7039 """GET /piano-roll/{ref}/{path} (single track) returns 200."""
7040 await _make_repo(db_session)
7041 response = await client.get(
7042 "/testuser/test-beats/piano-roll/main/tracks/bass.mid"
7043 )
7044 assert response.status_code == 200
7045
7046
7047 @pytest.mark.anyio
7048 async def test_piano_roll_track_page_embeds_path(
7049 client: AsyncClient,
7050 db_session: AsyncSession,
7051 ) -> None:
7052 """Single-track piano roll page embeds the MIDI file path in the JS context."""
7053 await _make_repo(db_session)
7054 response = await client.get(
7055 "/testuser/test-beats/piano-roll/main/tracks/bass.mid"
7056 )
7057 assert response.status_code == 200
7058 assert "tracks/bass.mid" in response.text
7059
7060
7061 @pytest.mark.anyio
7062 async def test_piano_roll_js_served(client: AsyncClient) -> None:
7063 """GET /static/piano-roll.js returns 200 JavaScript."""
7064 response = await client.get("/static/piano-roll.js")
7065 assert response.status_code == 200
7066 assert "javascript" in response.headers.get("content-type", "")
7067
7068
7069 @pytest.mark.anyio
7070 async def test_piano_roll_js_contains_renderer(client: AsyncClient) -> None:
7071 """piano-roll.js exports the PianoRoll.render function."""
7072 response = await client.get("/static/piano-roll.js")
7073 assert response.status_code == 200
7074 body = response.text
7075 assert "PianoRoll" in body
7076 assert "render" in body
7077
7078
7079
7080 async def _seed_blob_fixtures(db_session: AsyncSession) -> str:
7081 """Seed a public repo with a branch and typed objects for blob viewer tests.
7082
7083 Creates:
7084 - repo: testuser/blob-test (public)
7085 - branch: main
7086 - objects: tracks/bass.mid, tracks/keys.mp3, metadata.json, cover.webp
7087
7088 Returns repo_id.
7089 """
7090 repo = MusehubRepo(
7091 name="blob-test",
7092 owner="testuser",
7093 slug="blob-test",
7094 visibility="public",
7095 owner_user_id="test-owner",
7096 )
7097 db_session.add(repo)
7098 await db_session.flush()
7099
7100 commit = MusehubCommit(
7101 commit_id="blobdeadbeef12",
7102 repo_id=str(repo.repo_id),
7103 message="add blob fixtures",
7104 branch="main",
7105 author="testuser",
7106 timestamp=datetime.now(tz=UTC),
7107 )
7108 db_session.add(commit)
7109
7110 branch = MusehubBranch(
7111 repo_id=str(repo.repo_id),
7112 name="main",
7113 head_commit_id="blobdeadbeef12",
7114 )
7115 db_session.add(branch)
7116
7117 for path, size in [
7118 ("tracks/bass.mid", 2048),
7119 ("tracks/keys.mp3", 8192),
7120 ("metadata.json", 512),
7121 ("cover.webp", 4096),
7122 ]:
7123 obj = MusehubObject(
7124 object_id=f"sha256:blob_{path.replace('/', '_')}",
7125 repo_id=str(repo.repo_id),
7126 path=path,
7127 size_bytes=size,
7128 disk_path=f"/tmp/blob_{path.replace('/', '_')}",
7129 )
7130 db_session.add(obj)
7131
7132 await db_session.commit()
7133 return str(repo.repo_id)
7134
7135
7136
7137 @pytest.mark.anyio
7138 async def test_blob_404_unknown_path(
7139 client: AsyncClient,
7140 db_session: AsyncSession,
7141 ) -> None:
7142 """GET /api/v1/repos/{repo_id}/blob/{ref}/{path} returns 404 for unknown path."""
7143 repo_id = await _seed_blob_fixtures(db_session)
7144 response = await client.get(f"/api/v1/repos/{repo_id}/blob/main/does/not/exist.mid")
7145 assert response.status_code == 404
7146
7147
7148 @pytest.mark.anyio
7149 async def test_blob_image_shows_inline(
7150 client: AsyncClient,
7151 db_session: AsyncSession,
7152 ) -> None:
7153 """Blob page for .webp file includes <img> rendering logic in the template JS."""
7154 await _seed_blob_fixtures(db_session)
7155 response = await client.get("/testuser/blob-test/blob/main/cover.webp")
7156 assert response.status_code == 200
7157 body = response.text
7158 # JS template emits <img> for image file type
7159 assert "<img" in body or "blob-img" in body
7160 assert "cover.webp" in body
7161
7162
7163 @pytest.mark.anyio
7164 async def test_blob_json_response(
7165 client: AsyncClient,
7166 db_session: AsyncSession,
7167 ) -> None:
7168 """GET /api/v1/repos/{repo_id}/blob/{ref}/{path} returns BlobMetaResponse JSON."""
7169 repo_id = await _seed_blob_fixtures(db_session)
7170 response = await client.get(
7171 f"/api/v1/repos/{repo_id}/blob/main/tracks/bass.mid"
7172 )
7173 assert response.status_code == 200
7174 data = response.json()
7175 assert data["path"] == "tracks/bass.mid"
7176 assert data["filename"] == "bass.mid"
7177 assert data["sizeBytes"] == 2048
7178 assert data["fileType"] == "midi"
7179 assert data["sha"].startswith("sha256:")
7180 assert "/raw/" in data["rawUrl"]
7181 # MIDI is binary — no content_text
7182 assert data["contentText"] is None
7183 @pytest.mark.anyio
7184 async def test_blob_json_syntax_highlighted(
7185 client: AsyncClient,
7186 db_session: AsyncSession,
7187 ) -> None:
7188 """Blob page for .json file includes syntax-highlighting logic in the template JS."""
7189 await _seed_blob_fixtures(db_session)
7190 response = await client.get("/testuser/blob-test/blob/main/metadata.json")
7191 assert response.status_code == 200
7192 body = response.text
7193 # highlightJson function must be present in the template script
7194 assert "highlightJson" in body or "json-key" in body
7195 assert "metadata.json" in body
7196
7197
7198 @pytest.mark.anyio
7199 async def test_blob_midi_shows_piano_roll_link(
7200 client: AsyncClient,
7201 db_session: AsyncSession,
7202 ) -> None:
7203 """GET /{owner}/{repo}/blob/{ref}/{path} returns 200 HTML for a .mid file.
7204
7205 The template's client-side JS must reference the piano roll URL pattern so that
7206 clicking the page in a browser navigates to the piano roll viewer.
7207 """
7208 await _seed_blob_fixtures(db_session)
7209 response = await client.get("/testuser/blob-test/blob/main/tracks/bass.mid")
7210 assert response.status_code == 200
7211 assert "text/html" in response.headers["content-type"]
7212 body = response.text
7213 # JS in the template constructs piano-roll URLs for MIDI files
7214 assert "piano-roll" in body or "Piano Roll" in body
7215 # Filename is embedded in the page context
7216 assert "bass.mid" in body
7217
7218
7219 @pytest.mark.anyio
7220 async def test_blob_mp3_shows_audio_player(
7221 client: AsyncClient,
7222 db_session: AsyncSession,
7223 ) -> None:
7224 """Blob page for .mp3 file includes <audio> rendering logic in the template JS."""
7225 await _seed_blob_fixtures(db_session)
7226 response = await client.get("/testuser/blob-test/blob/main/tracks/keys.mp3")
7227 assert response.status_code == 200
7228 body = response.text
7229 # JS template emits <audio> element for audio file type
7230 assert "<audio" in body or "blob-audio" in body
7231 assert "keys.mp3" in body
7232
7233
7234 @pytest.mark.anyio
7235 async def test_blob_raw_button(
7236 client: AsyncClient,
7237 db_session: AsyncSession,
7238 ) -> None:
7239 """Blob page JS constructs a Raw download link via the /raw/ endpoint."""
7240 await _seed_blob_fixtures(db_session)
7241 response = await client.get("/testuser/blob-test/blob/main/tracks/bass.mid")
7242 assert response.status_code == 200
7243 body = response.text
7244 # JS constructs raw URL — the string '/raw/' must appear in the template script
7245 assert "/raw/" in body
7246
7247
7248 @pytest.mark.anyio
7249 async def test_score_page_contains_legend(
7250 client: AsyncClient,
7251 db_session: AsyncSession,
7252 ) -> None:
7253 """Score page includes a legend for note symbols."""
7254 await _make_repo(db_session)
7255 response = await client.get("/testuser/test-beats/score/main")
7256 assert response.status_code == 200
7257 body = response.text
7258 assert "legend" in body or "Note" in body
7259
7260
7261 @pytest.mark.anyio
7262 async def test_score_page_contains_score_meta(
7263 client: AsyncClient,
7264 db_session: AsyncSession,
7265 ) -> None:
7266 """Score page embeds a score metadata panel (key/tempo/time signature)."""
7267 await _make_repo(db_session)
7268 response = await client.get("/testuser/test-beats/score/main")
7269 assert response.status_code == 200
7270 body = response.text
7271 assert "score-meta" in body
7272
7273
7274 @pytest.mark.anyio
7275 async def test_score_page_contains_staff_container(
7276 client: AsyncClient,
7277 db_session: AsyncSession,
7278 ) -> None:
7279 """Score page embeds the SVG staff container markup."""
7280 await _make_repo(db_session)
7281 response = await client.get("/testuser/test-beats/score/main")
7282 assert response.status_code == 200
7283 body = response.text
7284 assert "staff-container" in body or "staves" in body
7285
7286
7287 @pytest.mark.anyio
7288 async def test_score_page_contains_track_selector(
7289 client: AsyncClient,
7290 db_session: AsyncSession,
7291 ) -> None:
7292 """Score page embeds a track selector element."""
7293 await _make_repo(db_session)
7294 response = await client.get("/testuser/test-beats/score/main")
7295 assert response.status_code == 200
7296 body = response.text
7297 assert "track-selector" in body
7298
7299
7300 @pytest.mark.anyio
7301 async def test_score_page_no_auth_required(
7302 client: AsyncClient,
7303 db_session: AsyncSession,
7304 ) -> None:
7305 """Score UI page must be accessible without an Authorization header."""
7306 await _make_repo(db_session)
7307 response = await client.get("/testuser/test-beats/score/main")
7308 assert response.status_code == 200
7309 assert response.status_code != 401
7310
7311
7312 @pytest.mark.anyio
7313 async def test_score_page_renders(
7314 client: AsyncClient,
7315 db_session: AsyncSession,
7316 ) -> None:
7317 """GET /{owner}/{slug}/score/{ref} returns 200 HTML."""
7318 await _make_repo(db_session)
7319 response = await client.get("/testuser/test-beats/score/main")
7320 assert response.status_code == 200
7321 assert "text/html" in response.headers["content-type"]
7322 body = response.text
7323 assert "MuseHub" in body
7324
7325
7326 @pytest.mark.anyio
7327 async def test_score_part_page_includes_path(
7328 client: AsyncClient,
7329 db_session: AsyncSession,
7330 ) -> None:
7331 """Single-part score page injects the path segment into page data."""
7332 await _make_repo(db_session)
7333 response = await client.get("/testuser/test-beats/score/main/piano")
7334 assert response.status_code == 200
7335 body = response.text
7336 # scorePath JS variable should be set to the path segment
7337 assert "piano" in body
7338
7339
7340 @pytest.mark.anyio
7341 async def test_score_part_page_renders(
7342 client: AsyncClient,
7343 db_session: AsyncSession,
7344 ) -> None:
7345 """GET /{owner}/{slug}/score/{ref}/{path} returns 200 HTML."""
7346 await _make_repo(db_session)
7347 response = await client.get("/testuser/test-beats/score/main/piano")
7348 assert response.status_code == 200
7349 assert "text/html" in response.headers["content-type"]
7350 body = response.text
7351 assert "MuseHub" in body
7352
7353
7354 @pytest.mark.anyio
7355 async def test_score_unknown_repo_404(
7356 client: AsyncClient,
7357 db_session: AsyncSession,
7358 ) -> None:
7359 """GET /{unknown}/{slug}/score/{ref} returns 404."""
7360 response = await client.get("/nobody/no-beats/score/main")
7361 assert response.status_code == 404
7362
7363
7364 # ---------------------------------------------------------------------------
7365 # Arrangement matrix page — # ---------------------------------------------------------------------------
7366
7367
7368 # ---------------------------------------------------------------------------
7369 # Piano roll page tests — # ---------------------------------------------------------------------------
7370
7371
7372 @pytest.mark.anyio
7373 async def test_ui_commit_page_artifact_auth_uses_blob_proxy(
7374 client: AsyncClient,
7375 db_session: AsyncSession,
7376 ) -> None:
7377 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7378
7379 The pre-SSR blob-proxy artifact pattern no longer applies — artifacts are loaded
7380 via the API. Non-existent commit SHAs now return 404 rather than an empty JS shell.
7381 """
7382 await _make_repo(db_session)
7383 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7384 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
7385 assert response.status_code == 404
7386
7387
7388 # ---------------------------------------------------------------------------
7389 # Reaction bars — # ---------------------------------------------------------------------------
7390
7391
7392 @pytest.mark.anyio
7393 async def test_reaction_bar_js_in_musehub_js(
7394 client: AsyncClient,
7395 db_session: AsyncSession,
7396 ) -> None:
7397 """musehub.js must define loadReactions and toggleReaction for all detail pages."""
7398 response = await client.get("/static/musehub.js")
7399 assert response.status_code == 200
7400 body = response.text
7401 assert "loadReactions" in body
7402 assert "toggleReaction" in body
7403 assert "REACTION_BAR_EMOJIS" in body
7404
7405
7406 @pytest.mark.anyio
7407 async def test_reaction_bar_emojis_in_musehub_js(
7408 client: AsyncClient,
7409 db_session: AsyncSession,
7410 ) -> None:
7411 """musehub.js reaction bar must include all 8 required emojis."""
7412 response = await client.get("/static/musehub.js")
7413 assert response.status_code == 200
7414 body = response.text
7415 for emoji in ["🔥", "❤️", "👏", "✨", "🎵", "🎸", "🎹", "🥁"]:
7416 assert emoji in body, f"Emoji {emoji!r} missing from musehub.js"
7417
7418
7419 @pytest.mark.anyio
7420 async def test_reaction_bar_commit_page_has_load_call(
7421 client: AsyncClient,
7422 db_session: AsyncSession,
7423 ) -> None:
7424 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7425
7426 Reactions are loaded via the API; the reaction bar is no longer a JS-only element
7427 in the SSR commit_detail.html template. Non-existent commits return 404.
7428 """
7429 await _make_repo(db_session)
7430 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7431 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
7432 assert response.status_code == 404
7433
7434
7435 @pytest.mark.anyio
7436 async def test_reaction_bar_pr_detail_has_load_call(
7437 client: AsyncClient,
7438 db_session: AsyncSession,
7439 ) -> None:
7440 """PR detail page renders SSR pull request content."""
7441 from musehub.db.musehub_models import MusehubPullRequest
7442 repo_id = await _make_repo(db_session)
7443 pr = MusehubPullRequest(
7444 repo_id=repo_id,
7445 title="Test PR for reaction bar",
7446 body="",
7447 state="open",
7448 from_branch="feat/test",
7449 to_branch="main",
7450 author="testuser",
7451 )
7452 db_session.add(pr)
7453 await db_session.commit()
7454 await db_session.refresh(pr)
7455 pr_id = str(pr.pr_id)
7456
7457 response = await client.get(f"/testuser/test-beats/pulls/{pr_id}")
7458 assert response.status_code == 200
7459 body = response.text
7460 assert "pr-detail-layout" in body
7461 assert pr_id[:8] in body
7462
7463
7464 @pytest.mark.anyio
7465 async def test_reaction_bar_issue_detail_has_load_call(
7466 client: AsyncClient,
7467 db_session: AsyncSession,
7468 ) -> None:
7469 """Issue detail page renders SSR issue content."""
7470 from musehub.db.musehub_models import MusehubIssue
7471 repo_id = await _make_repo(db_session)
7472 issue = MusehubIssue(
7473 repo_id=repo_id,
7474 number=1,
7475 title="Test issue for reaction bar",
7476 body="",
7477 state="open",
7478 labels=[],
7479 author="testuser",
7480 )
7481 db_session.add(issue)
7482 await db_session.commit()
7483
7484 response = await client.get("/testuser/test-beats/issues/1")
7485 assert response.status_code == 200
7486 body = response.text
7487 assert "issue-detail-grid" in body
7488 assert "Test issue for reaction bar" in body
7489
7490
7491 @pytest.mark.anyio
7492 async def test_reaction_bar_release_detail_has_load_call(
7493 client: AsyncClient,
7494 db_session: AsyncSession,
7495 ) -> None:
7496 """Release detail page renders SSR release content (includes loadReactions call)."""
7497 repo_id = await _make_repo(db_session)
7498 release = MusehubRelease(
7499 repo_id=repo_id,
7500 tag="v1.0",
7501 title="Test Release v1.0",
7502 body="Initial release notes.",
7503 author="testuser",
7504 )
7505 db_session.add(release)
7506 await db_session.commit()
7507
7508 response = await client.get("/testuser/test-beats/releases/v1.0")
7509 assert response.status_code == 200
7510 body = response.text
7511 assert "v1.0" in body
7512 assert "Test Release v1.0" in body
7513 assert "loadReactions" in body
7514 assert "release-reactions" in body
7515
7516
7517 @pytest.mark.anyio
7518 async def test_reaction_bar_session_detail_has_load_call(
7519 client: AsyncClient,
7520 db_session: AsyncSession,
7521 ) -> None:
7522 """Session detail page renders SSR session content."""
7523 repo_id = await _make_repo(db_session)
7524 session_id = await _make_session(db_session, repo_id)
7525
7526 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
7527 assert response.status_code == 200
7528 body = response.text
7529 assert "Session" in body
7530 assert session_id[:8] in body
7531
7532
7533 @pytest.mark.anyio
7534 async def test_reaction_api_allows_new_emojis(
7535 client: AsyncClient,
7536 db_session: AsyncSession,
7537 ) -> None:
7538 """POST /reactions with 👏 and 🎹 (new emojis) must be accepted (not 400)."""
7539 from musehub.db.musehub_models import MusehubRepo
7540 repo = MusehubRepo(
7541 name="reaction-test",
7542 owner="testuser",
7543 slug="reaction-test",
7544 visibility="public",
7545 owner_user_id="reaction-owner",
7546 )
7547 db_session.add(repo)
7548 await db_session.commit()
7549 await db_session.refresh(repo)
7550 repo_id = str(repo.repo_id)
7551
7552 token_headers = {"Authorization": "Bearer test-token"}
7553
7554 for emoji in ["👏", "🎹"]:
7555 response = await client.post(
7556 f"/api/v1/repos/{repo_id}/reactions",
7557 json={"target_type": "commit", "target_id": "abc123", "emoji": emoji},
7558 headers=token_headers,
7559 )
7560 assert response.status_code not in (400, 422), (
7561 f"Emoji {emoji!r} rejected by API: {response.status_code} {response.text}"
7562 )
7563
7564
7565 @pytest.mark.anyio
7566 async def test_reaction_api_allows_release_and_session_target_types(
7567 client: AsyncClient,
7568 db_session: AsyncSession,
7569 ) -> None:
7570 """POST /reactions must accept 'release' and 'session' as target_type values.
7571
7572 These target types were added to support reaction bars on
7573 release_detail and session_detail pages.
7574 """
7575 from musehub.db.musehub_models import MusehubRepo
7576 repo = MusehubRepo(
7577 name="target-type-test",
7578 owner="testuser",
7579 slug="target-type-test",
7580 visibility="public",
7581 owner_user_id="target-type-owner",
7582 )
7583 db_session.add(repo)
7584 await db_session.commit()
7585 await db_session.refresh(repo)
7586 repo_id = str(repo.repo_id)
7587
7588 token_headers = {"Authorization": "Bearer test-token"}
7589
7590 for target_type in ["release", "session"]:
7591 response = await client.post(
7592 f"/api/v1/repos/{repo_id}/reactions",
7593 json={"target_type": target_type, "target_id": "some-id", "emoji": "🔥"},
7594 headers=token_headers,
7595 )
7596 assert response.status_code not in (400, 422), (
7597 f"target_type {target_type!r} rejected: {response.status_code} {response.text}"
7598 )
7599
7600
7601 @pytest.mark.anyio
7602 async def test_reaction_bar_css_loaded_on_detail_pages(
7603 client: AsyncClient,
7604 db_session: AsyncSession,
7605 ) -> None:
7606 """Detail pages return 200 and load app.css (base stylesheet)."""
7607 from musehub.db.musehub_models import MusehubIssue, MusehubPullRequest
7608 repo_id = await _make_repo(db_session)
7609
7610 pr = MusehubPullRequest(
7611 repo_id=repo_id,
7612 title="CSS test PR",
7613 body="",
7614 state="open",
7615 from_branch="feat/css",
7616 to_branch="main",
7617 author="testuser",
7618 )
7619 db_session.add(pr)
7620 issue = MusehubIssue(
7621 repo_id=repo_id,
7622 number=1,
7623 title="CSS test issue",
7624 body="",
7625 state="open",
7626 labels=[],
7627 author="testuser",
7628 )
7629 db_session.add(issue)
7630 release = MusehubRelease(
7631 repo_id=repo_id,
7632 tag="v1.0",
7633 title="CSS test release",
7634 body="",
7635 author="testuser",
7636 )
7637 db_session.add(release)
7638 await db_session.commit()
7639 await db_session.refresh(pr)
7640 pr_id = str(pr.pr_id)
7641 session_id = await _make_session(db_session, repo_id)
7642
7643 pages = [
7644 f"/testuser/test-beats/pulls/{pr_id}",
7645 "/testuser/test-beats/issues/1",
7646 "/testuser/test-beats/releases/v1.0",
7647 f"/testuser/test-beats/sessions/{session_id}",
7648 ]
7649 for page in pages:
7650 response = await client.get(page)
7651 assert response.status_code == 200, f"Expected 200 for {page}, got {response.status_code}"
7652 assert "app.css" in response.text, f"app.css missing from {page}"
7653
7654
7655 @pytest.mark.anyio
7656 async def test_reaction_bar_components_css_has_styles(
7657 client: AsyncClient,
7658 db_session: AsyncSession,
7659 ) -> None:
7660 """components.css must define .reaction-bar and .reaction-btn CSS classes."""
7661 response = await client.get("/static/components.css")
7662 assert response.status_code == 200
7663 body = response.text
7664 assert ".reaction-bar" in body
7665 assert ".reaction-btn" in body
7666 assert ".reaction-btn--active" in body
7667 assert ".reaction-count" in body
7668
7669
7670 # ---------------------------------------------------------------------------
7671 # Feed page tests — (rich event cards)
7672 # ---------------------------------------------------------------------------
7673
7674
7675 @pytest.mark.anyio
7676 async def test_feed_page_returns_200(
7677 client: AsyncClient,
7678 db_session: AsyncSession,
7679 ) -> None:
7680 """GET /feed returns 200 HTML without requiring a JWT."""
7681 response = await client.get("/feed")
7682 assert response.status_code == 200
7683 assert "text/html" in response.headers["content-type"]
7684 assert "Activity Feed" in response.text
7685
7686
7687 @pytest.mark.anyio
7688 async def test_feed_page_no_raw_json_payload(
7689 client: AsyncClient,
7690 db_session: AsyncSession,
7691 ) -> None:
7692 """Feed page must not render raw JSON.stringify of notification payload.
7693
7694 Regression guard: the old implementation called
7695 JSON.stringify(item.payload) directly into the DOM, exposing raw JSON
7696 to users. The new rich card templates must not do this.
7697 """
7698 response = await client.get("/feed")
7699 assert response.status_code == 200
7700 body = response.text
7701 assert "JSON.stringify(item.payload" not in body
7702 assert "JSON.stringify(item" not in body
7703
7704
7705 @pytest.mark.anyio
7706 async def test_feed_page_has_event_meta_for_all_types(
7707 client: AsyncClient,
7708 db_session: AsyncSession,
7709 ) -> None:
7710 """Feed page must define EVENT_META entries for all 8 notification event types."""
7711 response = await client.get("/feed")
7712 assert response.status_code == 200
7713 body = response.text
7714 for event_type in (
7715 "comment",
7716 "mention",
7717 "pr_opened",
7718 "pr_merged",
7719 "issue_opened",
7720 "issue_closed",
7721 "new_commit",
7722 "new_follower",
7723 ):
7724 assert event_type in body, f"EVENT_META missing entry for '{event_type}'"
7725
7726
7727 @pytest.mark.anyio
7728 async def test_feed_page_has_data_notif_id_attribute(
7729 client: AsyncClient,
7730 db_session: AsyncSession,
7731 ) -> None:
7732 """Each event card must carry a data-notif-id attribute.
7733
7734 This attribute is the hook that (mark-as-read UX) will use to
7735 attach action buttons to each card without restructuring the DOM.
7736 """
7737 response = await client.get("/feed")
7738 assert response.status_code == 200
7739 assert "data-notif-id" in response.text
7740
7741
7742 @pytest.mark.anyio
7743 async def test_feed_page_has_unread_indicator(
7744 client: AsyncClient,
7745 db_session: AsyncSession,
7746 ) -> None:
7747 """Feed page must include logic to highlight unread cards with a left border."""
7748 response = await client.get("/feed")
7749 assert response.status_code == 200
7750 body = response.text
7751 assert "is_read" in body
7752 assert "color-accent" in body
7753
7754
7755 @pytest.mark.anyio
7756 async def test_feed_page_has_actor_avatar_logic(
7757 client: AsyncClient,
7758 db_session: AsyncSession,
7759 ) -> None:
7760 """Feed page must render actor avatars using the actorHsl / actorAvatar helpers."""
7761 response = await client.get("/feed")
7762 assert response.status_code == 200
7763 body = response.text
7764 assert "actorHsl" in body
7765 assert "actorAvatar" in body
7766
7767
7768 @pytest.mark.anyio
7769 async def test_feed_page_has_relative_timestamp(
7770 client: AsyncClient,
7771 db_session: AsyncSession,
7772 ) -> None:
7773 """Feed page must call fmtRelative to render timestamps in a human-readable form."""
7774 response = await client.get("/feed")
7775 assert response.status_code == 200
7776 assert "fmtRelative" in response.text
7777
7778
7779 # ---------------------------------------------------------------------------
7780 # Mark-as-read UX tests — # ---------------------------------------------------------------------------
7781
7782
7783 @pytest.mark.anyio
7784 async def test_feed_page_has_mark_one_read_function(
7785 client: AsyncClient,
7786 db_session: AsyncSession,
7787 ) -> None:
7788 """Feed page must define markOneRead() for per-notification mark-as-read."""
7789 response = await client.get("/feed")
7790 assert response.status_code == 200
7791 assert "markOneRead" in response.text
7792
7793
7794 @pytest.mark.anyio
7795 async def test_feed_page_has_mark_all_read_function(
7796 client: AsyncClient,
7797 db_session: AsyncSession,
7798 ) -> None:
7799 """Feed page must define markAllRead() for bulk mark-as-read."""
7800 response = await client.get("/feed")
7801 assert response.status_code == 200
7802 assert "markAllRead" in response.text
7803
7804
7805 @pytest.mark.anyio
7806 async def test_feed_page_has_decrement_nav_badge_function(
7807 client: AsyncClient,
7808 db_session: AsyncSession,
7809 ) -> None:
7810 """Feed page must define decrementNavBadge() to keep the nav badge in sync."""
7811 response = await client.get("/feed")
7812 assert response.status_code == 200
7813 assert "decrementNavBadge" in response.text
7814
7815
7816 @pytest.mark.anyio
7817 async def test_feed_page_mark_read_btn_targets_notification_endpoint(
7818 client: AsyncClient,
7819 db_session: AsyncSession,
7820 ) -> None:
7821 """markOneRead() must call POST /notifications/{notif_id}/read."""
7822 response = await client.get("/feed")
7823 assert response.status_code == 200
7824 body = response.text
7825 assert "/notifications/" in body
7826 assert "mark-read-btn" in body
7827
7828
7829 @pytest.mark.anyio
7830 async def test_feed_page_mark_all_btn_targets_read_all_endpoint(
7831 client: AsyncClient,
7832 db_session: AsyncSession,
7833 ) -> None:
7834 """markAllRead() must call POST /notifications/read-all."""
7835 response = await client.get("/feed")
7836 assert response.status_code == 200
7837 assert "read-all" in response.text
7838
7839
7840 @pytest.mark.anyio
7841 async def test_feed_page_mark_all_btn_present_in_template(
7842 client: AsyncClient,
7843 db_session: AsyncSession,
7844 ) -> None:
7845 """Feed page must render a 'Mark all as read' button element."""
7846 response = await client.get("/feed")
7847 assert response.status_code == 200
7848 assert "mark-all-read-btn" in response.text
7849
7850
7851 @pytest.mark.anyio
7852 async def test_feed_page_mark_read_updates_nav_badge(
7853 client: AsyncClient,
7854 db_session: AsyncSession,
7855 ) -> None:
7856 """After marking all as read, page logic must update nav-notif-badge to hidden."""
7857 response = await client.get("/feed")
7858 assert response.status_code == 200
7859 body = response.text
7860 assert "nav-notif-badge" in body
7861 assert "decrementNavBadge" in body
7862
7863
7864 # ---------------------------------------------------------------------------
7865 # Per-dimension analysis detail pages
7866 # ---------------------------------------------------------------------------
7867
7868
7869 @pytest.mark.anyio
7870 async def test_key_analysis_page_renders(
7871 client: AsyncClient,
7872 db_session: AsyncSession,
7873 ) -> None:
7874 """GET /{owner}/{repo_slug}/analysis/{ref}/key returns 200 HTML."""
7875 await _make_repo(db_session)
7876 ref = "abc1234567890abcdef"
7877 response = await client.get(f"/testuser/test-beats/analysis/{ref}/key")
7878 assert response.status_code == 200
7879 assert "text/html" in response.headers["content-type"]
7880
7881
7882 @pytest.mark.anyio
7883 async def test_key_analysis_page_no_auth_required(
7884 client: AsyncClient,
7885 db_session: AsyncSession,
7886 ) -> None:
7887 """Key analysis page must be accessible without a JWT (HTML shell handles auth)."""
7888 await _make_repo(db_session)
7889 ref = "deadbeef1234"
7890 response = await client.get(f"/testuser/test-beats/analysis/{ref}/key")
7891 assert response.status_code != 401
7892 assert response.status_code == 200
7893
7894
7895 @pytest.mark.anyio
7896 async def test_key_analysis_page_contains_key_data_labels(
7897 client: AsyncClient,
7898 db_session: AsyncSession,
7899 ) -> None:
7900 """Key page must contain tonic, mode, relative key, and confidence UI elements."""
7901 await _make_repo(db_session)
7902 ref = "cafebabe12345678"
7903 response = await client.get(f"/testuser/test-beats/analysis/{ref}/key")
7904 assert response.status_code == 200
7905 body = response.text
7906 assert "Key Detection" in body
7907 assert "Relative Key" in body
7908 assert "Detection Confidence" in body
7909 assert "Alternate Key" in body
7910
7911
7912 @pytest.mark.anyio
7913 async def test_meter_analysis_page_renders(
7914 client: AsyncClient,
7915 db_session: AsyncSession,
7916 ) -> None:
7917 """GET /{owner}/{repo_slug}/analysis/{ref}/meter returns 200 HTML."""
7918 await _make_repo(db_session)
7919 ref = "abc1234567890abcdef"
7920 response = await client.get(f"/testuser/test-beats/analysis/{ref}/meter")
7921 assert response.status_code == 200
7922 assert "text/html" in response.headers["content-type"]
7923
7924
7925 @pytest.mark.anyio
7926 async def test_meter_analysis_page_no_auth_required(
7927 client: AsyncClient,
7928 db_session: AsyncSession,
7929 ) -> None:
7930 """Meter analysis page must be accessible without a JWT (HTML shell handles auth)."""
7931 await _make_repo(db_session)
7932 ref = "deadbeef5678"
7933 response = await client.get(f"/testuser/test-beats/analysis/{ref}/meter")
7934 assert response.status_code != 401
7935 assert response.status_code == 200
7936
7937
7938 @pytest.mark.anyio
7939 async def test_meter_analysis_page_contains_meter_data_labels(
7940 client: AsyncClient,
7941 db_session: AsyncSession,
7942 ) -> None:
7943 """Meter page must contain time signature, compound/simple badge, and beat strength UI."""
7944 await _make_repo(db_session)
7945 ref = "feedface5678"
7946 response = await client.get(f"/testuser/test-beats/analysis/{ref}/meter")
7947 assert response.status_code == 200
7948 body = response.text
7949 assert "Meter Analysis" in body
7950 assert "Time Signature" in body
7951 assert "Beat Strength Profile" in body
7952 # SSR migration (issue #578): beat strength is now rendered as inline CSS bars,
7953 # not as a JS function call. Verify the label is present and CSS bars are rendered.
7954 assert "border-radius" in body or "%" in body
7955
7956
7957 @pytest.mark.anyio
7958 async def test_chord_map_analysis_page_renders(
7959 client: AsyncClient,
7960 db_session: AsyncSession,
7961 ) -> None:
7962 """GET /{owner}/{repo_slug}/analysis/{ref}/chord-map returns 200 HTML."""
7963 await _make_repo(db_session)
7964 ref = "abc1234567890abcdef"
7965 response = await client.get(f"/testuser/test-beats/analysis/{ref}/chord-map")
7966 assert response.status_code == 200
7967 assert "text/html" in response.headers["content-type"]
7968
7969
7970 @pytest.mark.anyio
7971 async def test_chord_map_analysis_page_no_auth_required(
7972 client: AsyncClient,
7973 db_session: AsyncSession,
7974 ) -> None:
7975 """Chord-map analysis page must be accessible without a JWT (HTML shell handles auth)."""
7976 await _make_repo(db_session)
7977 ref = "deadbeef9999"
7978 response = await client.get(f"/testuser/test-beats/analysis/{ref}/chord-map")
7979 assert response.status_code != 401
7980 assert response.status_code == 200
7981
7982
7983 @pytest.mark.anyio
7984 async def test_chord_map_analysis_page_contains_chord_data_labels(
7985 client: AsyncClient,
7986 db_session: AsyncSession,
7987 ) -> None:
7988 """Chord-map page SSR: must contain progression timeline, chord table, and tension data."""
7989 await _make_repo(db_session)
7990 ref = "beefdead1234"
7991 response = await client.get(f"/testuser/test-beats/analysis/{ref}/chord-map")
7992 assert response.status_code == 200
7993 body = response.text
7994 assert "Chord Map" in body
7995 assert "PROGRESSION TIMELINE" in body
7996 assert "CHORD TABLE" in body
7997 assert "tension" in body.lower()
7998
7999
8000 @pytest.mark.anyio
8001 async def test_groove_analysis_page_renders(
8002 client: AsyncClient,
8003 db_session: AsyncSession,
8004 ) -> None:
8005 """GET /{owner}/{repo_slug}/analysis/{ref}/groove returns 200 HTML."""
8006 await _make_repo(db_session)
8007 ref = "abc1234567890abcdef"
8008 response = await client.get(f"/testuser/test-beats/analysis/{ref}/groove")
8009 assert response.status_code == 200
8010 assert "text/html" in response.headers["content-type"]
8011
8012
8013 @pytest.mark.anyio
8014 async def test_groove_analysis_page_no_auth_required(
8015 client: AsyncClient,
8016 db_session: AsyncSession,
8017 ) -> None:
8018 """Groove analysis page must be accessible without a JWT (HTML shell handles auth)."""
8019 await _make_repo(db_session)
8020 ref = "deadbeef4321"
8021 response = await client.get(f"/testuser/test-beats/analysis/{ref}/groove")
8022 assert response.status_code != 401
8023 assert response.status_code == 200
8024
8025
8026 @pytest.mark.anyio
8027 async def test_groove_analysis_page_contains_groove_data_labels(
8028 client: AsyncClient,
8029 db_session: AsyncSession,
8030 ) -> None:
8031 """Groove page must contain style badge, BPM, swing factor, and groove score UI."""
8032 await _make_repo(db_session)
8033 ref = "cafefeed5678"
8034 response = await client.get(f"/testuser/test-beats/analysis/{ref}/groove")
8035 assert response.status_code == 200
8036 body = response.text
8037 assert "Groove Analysis" in body
8038 assert "Style" in body
8039 assert "BPM" in body
8040 assert "Groove Score" in body
8041 assert "Swing Factor" in body
8042
8043
8044 @pytest.mark.anyio
8045 async def test_emotion_analysis_page_renders(
8046 client: AsyncClient,
8047 db_session: AsyncSession,
8048 ) -> None:
8049 """GET /{owner}/{repo_slug}/analysis/{ref}/emotion returns 200 HTML."""
8050 await _make_repo(db_session)
8051 ref = "abc1234567890abcdef"
8052 response = await client.get(f"/testuser/test-beats/analysis/{ref}/emotion")
8053 assert response.status_code == 200
8054 assert "text/html" in response.headers["content-type"]
8055
8056
8057 @pytest.mark.anyio
8058 async def test_emotion_analysis_page_no_auth_required(
8059 client: AsyncClient,
8060 db_session: AsyncSession,
8061 ) -> None:
8062 """Emotion analysis page must be accessible without a JWT (HTML shell handles auth)."""
8063 await _make_repo(db_session)
8064 ref = "deadbeef0001"
8065 response = await client.get(f"/testuser/test-beats/analysis/{ref}/emotion")
8066 assert response.status_code != 401
8067 assert response.status_code == 200
8068
8069
8070 @pytest.mark.anyio
8071 async def test_emotion_analysis_page_contains_emotion_data_labels(
8072 client: AsyncClient,
8073 db_session: AsyncSession,
8074 ) -> None:
8075 """Emotion page SSR: must contain SVG scatter plot and summary vector dimension bars."""
8076 await _make_repo(db_session)
8077 ref = "aabbccdd5678"
8078 response = await client.get(f"/testuser/test-beats/analysis/{ref}/emotion")
8079 assert response.status_code == 200
8080 body = response.text
8081 assert "Emotion Analysis" in body
8082 assert "SUMMARY VECTOR" in body
8083 assert "Valence" in body or "valence" in body
8084 assert "Tension" in body or "tension" in body
8085 assert "<circle" in body or "<svg" in body
8086
8087
8088 @pytest.mark.anyio
8089 async def test_form_analysis_page_renders(
8090 client: AsyncClient,
8091 db_session: AsyncSession,
8092 ) -> None:
8093 """GET /{owner}/{repo_slug}/analysis/{ref}/form returns 200 HTML."""
8094 await _make_repo(db_session)
8095 ref = "abc1234567890abcdef"
8096 response = await client.get(f"/testuser/test-beats/analysis/{ref}/form")
8097 assert response.status_code == 200
8098 assert "text/html" in response.headers["content-type"]
8099
8100
8101 @pytest.mark.anyio
8102 async def test_form_analysis_page_no_auth_required(
8103 client: AsyncClient,
8104 db_session: AsyncSession,
8105 ) -> None:
8106 """Form analysis page must be accessible without a JWT (HTML shell handles auth)."""
8107 await _make_repo(db_session)
8108 ref = "deadbeef0002"
8109 response = await client.get(f"/testuser/test-beats/analysis/{ref}/form")
8110 assert response.status_code != 401
8111 assert response.status_code == 200
8112
8113
8114 @pytest.mark.anyio
8115 async def test_form_analysis_page_contains_form_data_labels(
8116 client: AsyncClient,
8117 db_session: AsyncSession,
8118 ) -> None:
8119 """Form page must contain form label, section timeline, and sections table."""
8120 await _make_repo(db_session)
8121 ref = "11223344abcd"
8122 response = await client.get(f"/testuser/test-beats/analysis/{ref}/form")
8123 assert response.status_code == 200
8124 body = response.text
8125 assert "Form Analysis" in body
8126 assert "Form Timeline" in body or "formLabel" in body
8127 assert "Sections" in body
8128 assert "Total Beats" in body
8129
8130
8131 # ---------------------------------------------------------------------------
8132 # Issue #295 — Profile page: followers/following lists with user cards
8133 # ---------------------------------------------------------------------------
8134
8135 # test_profile_page_has_followers_following_tabs
8136 # test_profile_page_has_user_card_js
8137 # test_profile_page_has_switch_tab_js
8138 # test_followers_list_endpoint_returns_200
8139 # test_followers_list_returns_user_cards_for_known_user
8140 # test_following_list_returns_user_cards_for_known_user
8141 # test_followers_list_unknown_user_404
8142 # test_following_list_unknown_user_404
8143 # test_followers_response_includes_following_count
8144 # test_followers_list_empty_for_user_with_no_followers
8145
8146
8147 async def _make_follow(
8148 db_session: AsyncSession,
8149 follower_id: str,
8150 followee_id: str,
8151 ) -> MusehubFollow:
8152 """Seed a follow relationship and return the ORM row."""
8153 import uuid
8154 row = MusehubFollow(
8155 follow_id=str(uuid.uuid4()),
8156 follower_id=follower_id,
8157 followee_id=followee_id,
8158 )
8159 db_session.add(row)
8160 await db_session.commit()
8161 return row
8162
8163
8164 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
8165 @pytest.mark.anyio
8166 async def test_profile_page_has_followers_following_tabs(
8167 client: AsyncClient,
8168 db_session: AsyncSession,
8169 ) -> None:
8170 """Profile page must render Followers and Following tab buttons."""
8171 await _make_profile(db_session, username="tabuser")
8172 response = await client.get("/users/tabuser")
8173 assert response.status_code == 200
8174 body = response.text
8175 assert "tab-btn-followers" in body
8176 assert "tab-btn-following" in body
8177
8178
8179 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
8180 @pytest.mark.anyio
8181 async def test_profile_page_has_user_card_js(
8182 client: AsyncClient,
8183 db_session: AsyncSession,
8184 ) -> None:
8185 """Profile page must include userCardHtml and loadFollowTab JS helpers."""
8186 await _make_profile(db_session, username="cardjsuser")
8187 response = await client.get("/users/cardjsuser")
8188 assert response.status_code == 200
8189 body = response.text
8190 assert "userCardHtml" in body
8191 assert "loadFollowTab" in body
8192
8193
8194 @pytest.mark.anyio
8195 async def test_profile_page_has_switch_tab_js(
8196 client: AsyncClient,
8197 db_session: AsyncSession,
8198 ) -> None:
8199 """Profile page must include switchTab() to toggle between followers and following."""
8200 await _make_profile(db_session, username="switchtabuser")
8201 response = await client.get("/switchtabuser")
8202 assert response.status_code == 200
8203 # switchTab moved to app.js TypeScript module; check page dispatch and tab structure
8204 assert '"page": "user-profile"' in response.text
8205 assert "tab-btn" in response.text
8206
8207
8208 @pytest.mark.anyio
8209 async def test_followers_list_endpoint_returns_200(
8210 client: AsyncClient,
8211 db_session: AsyncSession,
8212 ) -> None:
8213 """GET /api/v1/users/{username}/followers-list returns 200 for known user."""
8214 await _make_profile(db_session, username="followerlistuser")
8215 response = await client.get("/api/v1/users/followerlistuser/followers-list")
8216 assert response.status_code == 200
8217 assert isinstance(response.json(), list)
8218
8219
8220 @pytest.mark.anyio
8221 async def test_followers_list_returns_user_cards_for_known_user(
8222 client: AsyncClient,
8223 db_session: AsyncSession,
8224 ) -> None:
8225 """followers-list returns UserCard objects when followers exist."""
8226 import uuid
8227
8228 target = MusehubProfile(
8229 user_id="target-user-fl-01",
8230 username="flctarget",
8231 bio="I am the target",
8232 avatar_url=None,
8233 pinned_repo_ids=[],
8234 )
8235 follower = MusehubProfile(
8236 user_id="follower-user-fl-01",
8237 username="flcfollower",
8238 bio="I am a follower",
8239 avatar_url=None,
8240 pinned_repo_ids=[],
8241 )
8242 db_session.add(target)
8243 db_session.add(follower)
8244 await db_session.flush()
8245 # Seed a follow row using user_ids (same convention as the seed script)
8246 await _make_follow(db_session, follower_id="follower-user-fl-01", followee_id="target-user-fl-01")
8247
8248 response = await client.get("/api/v1/users/flctarget/followers-list")
8249 assert response.status_code == 200
8250 cards = response.json()
8251 assert len(cards) >= 1
8252 usernames = [c["username"] for c in cards]
8253 assert "flcfollower" in usernames
8254
8255
8256 @pytest.mark.anyio
8257 async def test_following_list_returns_user_cards_for_known_user(
8258 client: AsyncClient,
8259 db_session: AsyncSession,
8260 ) -> None:
8261 """following-list returns UserCard objects for users that the target follows."""
8262 actor = MusehubProfile(
8263 user_id="actor-user-fl-02",
8264 username="flcactor",
8265 bio="I follow people",
8266 avatar_url=None,
8267 pinned_repo_ids=[],
8268 )
8269 followee = MusehubProfile(
8270 user_id="followee-user-fl-02",
8271 username="flcfollowee",
8272 bio="I am followed",
8273 avatar_url=None,
8274 pinned_repo_ids=[],
8275 )
8276 db_session.add(actor)
8277 db_session.add(followee)
8278 await db_session.flush()
8279 await _make_follow(db_session, follower_id="actor-user-fl-02", followee_id="followee-user-fl-02")
8280
8281 response = await client.get("/api/v1/users/flcactor/following-list")
8282 assert response.status_code == 200
8283 cards = response.json()
8284 assert len(cards) >= 1
8285 usernames = [c["username"] for c in cards]
8286 assert "flcfollowee" in usernames
8287
8288
8289 @pytest.mark.anyio
8290 async def test_followers_list_unknown_user_404(
8291 client: AsyncClient,
8292 db_session: AsyncSession,
8293 ) -> None:
8294 """followers-list returns 404 when the target username does not exist."""
8295 response = await client.get("/api/v1/users/nonexistent-ghost-user/followers-list")
8296 assert response.status_code == 404
8297
8298
8299 @pytest.mark.anyio
8300 async def test_following_list_unknown_user_404(
8301 client: AsyncClient,
8302 db_session: AsyncSession,
8303 ) -> None:
8304 """following-list returns 404 when the target username does not exist."""
8305 response = await client.get("/api/v1/users/nonexistent-ghost-user/following-list")
8306 assert response.status_code == 404
8307
8308
8309 @pytest.mark.anyio
8310 async def test_followers_response_includes_following_count(
8311 client: AsyncClient,
8312 db_session: AsyncSession,
8313 ) -> None:
8314 """GET /users/{username}/followers now includes following_count in response."""
8315 await _make_profile(db_session, username="followcountuser")
8316 response = await client.get("/api/v1/users/followcountuser/followers")
8317 assert response.status_code == 200
8318 data = response.json()
8319 assert "followerCount" in data or "follower_count" in data
8320 assert "followingCount" in data or "following_count" in data
8321
8322
8323 @pytest.mark.anyio
8324 async def test_followers_list_empty_for_user_with_no_followers(
8325 client: AsyncClient,
8326 db_session: AsyncSession,
8327 ) -> None:
8328 """followers-list returns an empty list when no one follows the user."""
8329 await _make_profile(db_session, username="lonelyuser295")
8330 response = await client.get("/api/v1/users/lonelyuser295/followers-list")
8331 assert response.status_code == 200
8332 assert response.json() == []
8333
8334
8335 # ---------------------------------------------------------------------------
8336 # Issue #450 — Enhanced commit detail: inline audio player, muse_tags panel,
8337 # reactions, comment thread, cross-references
8338 # ---------------------------------------------------------------------------
8339
8340
8341 @pytest.mark.anyio
8342 async def test_commit_page_has_inline_audio_player_section(
8343 client: AsyncClient,
8344 db_session: AsyncSession,
8345 ) -> None:
8346 """Commit detail page (SSR, issue #583) renders WaveSurfer shell when snapshot_id is set.
8347
8348 Post-SSR migration: the audio player shell (commit-waveform + WaveSurfer script)
8349 is rendered only when the commit has a snapshot_id. Non-existent commits → 404.
8350 """
8351 from datetime import datetime, timezone
8352 from musehub.db.musehub_models import MusehubCommit
8353
8354 repo = MusehubRepo(
8355 name="audio-player-test",
8356 owner="audiouser",
8357 slug="audio-player-test",
8358 visibility="public",
8359 owner_user_id="audio-uid",
8360 )
8361 db_session.add(repo)
8362 await db_session.commit()
8363 await db_session.refresh(repo)
8364
8365 snap_id = "sha256:deadbeefcafe"
8366 commit_id = "c0ffee0000111122223333444455556666c0ffee"
8367 commit = MusehubCommit(
8368 commit_id=commit_id,
8369 repo_id=str(repo.repo_id),
8370 branch="main",
8371 parent_ids=[],
8372 message="Add audio snapshot",
8373 author="audiouser",
8374 timestamp=datetime.now(tz=timezone.utc),
8375 snapshot_id=snap_id,
8376 )
8377 db_session.add(commit)
8378 await db_session.commit()
8379
8380 response = await client.get(f"/audiouser/audio-player-test/commits/{commit_id}")
8381 assert response.status_code == 200
8382 body = response.text
8383 # SSR audio shell: waveform div with data-url set from snapshot_id
8384 assert "commit-waveform" in body
8385 assert snap_id in body
8386 # WaveSurfer vendor script still loaded
8387 assert "wavesurfer" in body.lower()
8388 # Listen link rendered
8389 assert "Listen" in body
8390
8391
8392 @pytest.mark.anyio
8393 async def test_commit_page_inline_player_has_track_selector_js(
8394 client: AsyncClient,
8395 db_session: AsyncSession,
8396 ) -> None:
8397 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8398
8399 Track selector JS was part of the pre-SSR commit.html. The new commit_detail.html
8400 renders a simplified WaveSurfer shell from the commit's snapshot_id.
8401 Non-existent commits return 404 rather than an empty JS shell.
8402 """
8403 repo = MusehubRepo(
8404 name="track-sel-test",
8405 owner="trackuser",
8406 slug="track-sel-test",
8407 visibility="public",
8408 owner_user_id="track-uid",
8409 )
8410 db_session.add(repo)
8411 await db_session.commit()
8412 await db_session.refresh(repo)
8413
8414 commit_id = "aaaa1111bbbb2222cccc3333dddd4444eeee5555"
8415 response = await client.get(f"/trackuser/track-sel-test/commits/{commit_id}")
8416 assert response.status_code == 404
8417
8418
8419 @pytest.mark.anyio
8420 async def test_commit_page_has_muse_tags_panel(
8421 client: AsyncClient,
8422 db_session: AsyncSession,
8423 ) -> None:
8424 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8425
8426 The muse-tags-panel was a JS-only construct in the pre-SSR commit.html.
8427 The new commit_detail.html renders metadata server-side; the muse-tags panel
8428 is not present. Non-existent commits return 404.
8429 """
8430 repo = MusehubRepo(
8431 name="tags-panel-test",
8432 owner="tagsuser",
8433 slug="tags-panel-test",
8434 visibility="public",
8435 owner_user_id="tags-uid",
8436 )
8437 db_session.add(repo)
8438 await db_session.commit()
8439 await db_session.refresh(repo)
8440
8441 commit_id = "1234567890abcdef1234567890abcdef12345678"
8442 response = await client.get(f"/tagsuser/tags-panel-test/commits/{commit_id}")
8443 assert response.status_code == 404
8444
8445
8446 @pytest.mark.anyio
8447 async def test_commit_page_muse_tags_pill_colours_defined(
8448 client: AsyncClient,
8449 db_session: AsyncSession,
8450 ) -> None:
8451 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8452
8453 Muse-pill CSS classes were part of the pre-SSR commit.html analysis panel.
8454 The new commit_detail.html does not include muse-pill classes.
8455 Non-existent commits return 404.
8456 """
8457 repo = MusehubRepo(
8458 name="pill-colour-test",
8459 owner="pilluser",
8460 slug="pill-colour-test",
8461 visibility="public",
8462 owner_user_id="pill-uid",
8463 )
8464 db_session.add(repo)
8465 await db_session.commit()
8466 await db_session.refresh(repo)
8467
8468 commit_id = "abcd1234ef567890abcd1234ef567890abcd1234"
8469 response = await client.get(f"/pilluser/pill-colour-test/commits/{commit_id}")
8470 assert response.status_code == 404
8471
8472
8473 @pytest.mark.anyio
8474 async def test_commit_page_has_cross_references_section(
8475 client: AsyncClient,
8476 db_session: AsyncSession,
8477 ) -> None:
8478 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8479
8480 The cross-references panel (xrefs-body, loadCrossReferences) was a JS-only
8481 construct in the pre-SSR commit.html. The new commit_detail.html does not
8482 include this panel. Non-existent commits return 404.
8483 """
8484 repo = MusehubRepo(
8485 name="xrefs-test",
8486 owner="xrefsuser",
8487 slug="xrefs-test",
8488 visibility="public",
8489 owner_user_id="xrefs-uid",
8490 )
8491 db_session.add(repo)
8492 await db_session.commit()
8493 await db_session.refresh(repo)
8494
8495 commit_id = "face000011112222333344445555666677778888"
8496 response = await client.get(f"/xrefsuser/xrefs-test/commits/{commit_id}")
8497 assert response.status_code == 404
8498
8499
8500 @pytest.mark.anyio
8501 async def test_commit_page_context_passes_listen_and_embed_urls(
8502 client: AsyncClient,
8503 db_session: AsyncSession,
8504 ) -> None:
8505 """commit_page() (SSR, issue #583) injects listenUrl and embedUrl into the JS page-data block.
8506
8507 The SSR template still exposes these URLs server-side for the JS and for
8508 navigation links. Requires the commit to exist in the DB.
8509 """
8510 from datetime import datetime, timezone
8511 from musehub.db.musehub_models import MusehubCommit
8512
8513 repo = MusehubRepo(
8514 name="url-context-test",
8515 owner="urluser",
8516 slug="url-context-test",
8517 visibility="public",
8518 owner_user_id="url-uid",
8519 )
8520 db_session.add(repo)
8521 await db_session.commit()
8522 await db_session.refresh(repo)
8523
8524 commit_id = "dead0000beef1111dead0000beef1111dead0000"
8525 commit = MusehubCommit(
8526 commit_id=commit_id,
8527 repo_id=str(repo.repo_id),
8528 branch="main",
8529 parent_ids=[],
8530 message="URL context test commit",
8531 author="urluser",
8532 timestamp=datetime.now(tz=timezone.utc),
8533 )
8534 db_session.add(commit)
8535 await db_session.commit()
8536
8537 response = await client.get(f"/urluser/url-context-test/commits/{commit_id}")
8538 assert response.status_code == 200
8539 body = response.text
8540 assert "listenUrl" in body
8541 assert "embedUrl" in body
8542 assert f"/listen/{commit_id}" in body
8543 assert f"/embed/{commit_id}" in body
8544
8545
8546 # ---------------------------------------------------------------------------
8547 # Issue #442 — Repo landing page enrichment panels
8548 # Explore page — filter sidebar + inline audio preview
8549 # ---------------------------------------------------------------------------
8550
8551
8552 @pytest.mark.anyio
8553 async def test_repo_home_contributors_panel_js(
8554 client: AsyncClient,
8555 db_session: AsyncSession,
8556 ) -> None:
8557 """Repo home page links to the credits page (SSR — no client-side contributor panel JS)."""
8558 repo = MusehubRepo(
8559 name="contrib-panel-test",
8560 owner="contribowner",
8561 slug="contrib-panel-test",
8562 visibility="public",
8563 owner_user_id="contrib-uid",
8564 )
8565 db_session.add(repo)
8566 await db_session.commit()
8567
8568 response = await client.get("/contribowner/contrib-panel-test")
8569 assert response.status_code == 200
8570 body = response.text
8571 assert "MuseHub" in body
8572 assert "contribowner" in body
8573 assert "contrib-panel-test" in body
8574
8575
8576 @pytest.mark.anyio
8577 async def test_repo_home_activity_heatmap_js(
8578 client: AsyncClient,
8579 db_session: AsyncSession,
8580 ) -> None:
8581 """Repo home page renders SSR repo metadata (no client-side heatmap JS)."""
8582 repo = MusehubRepo(
8583 name="heatmap-panel-test",
8584 owner="heatmapowner",
8585 slug="heatmap-panel-test",
8586 visibility="public",
8587 owner_user_id="heatmap-uid",
8588 )
8589 db_session.add(repo)
8590 await db_session.commit()
8591
8592 response = await client.get("/heatmapowner/heatmap-panel-test")
8593 assert response.status_code == 200
8594 body = response.text
8595 assert "MuseHub" in body
8596 assert "heatmapowner" in body
8597 assert "heatmap-panel-test" in body
8598
8599
8600 @pytest.mark.anyio
8601 async def test_repo_home_instrument_bar_js(
8602 client: AsyncClient,
8603 db_session: AsyncSession,
8604 ) -> None:
8605 """Repo home page renders SSR repo metadata (no client-side instrument-bar JS)."""
8606 repo = MusehubRepo(
8607 name="instrbar-panel-test",
8608 owner="instrbarowner",
8609 slug="instrbar-panel-test",
8610 visibility="public",
8611 owner_user_id="instrbar-uid",
8612 )
8613 db_session.add(repo)
8614 await db_session.commit()
8615
8616 response = await client.get("/instrbarowner/instrbar-panel-test")
8617 assert response.status_code == 200
8618 body = response.text
8619 assert "MuseHub" in body
8620 assert "instrbarowner" in body
8621 assert "instrbar-panel-test" in body
8622
8623
8624 @pytest.mark.anyio
8625 async def test_repo_home_clone_widget_renders(
8626 client: AsyncClient,
8627 db_session: AsyncSession,
8628 ) -> None:
8629 """Repo home page renders clone URLs server-side into read-only inputs."""
8630 repo = MusehubRepo(
8631 name="clone-widget-test",
8632 owner="cloneowner",
8633 slug="clone-widget-test",
8634 visibility="public",
8635 owner_user_id="clone-uid",
8636 )
8637 db_session.add(repo)
8638 await db_session.commit()
8639
8640 response = await client.get("/cloneowner/clone-widget-test")
8641 assert response.status_code == 200
8642 body = response.text
8643
8644 # Clone URLs injected server-side by repo_page()
8645 assert "musehub://cloneowner/clone-widget-test" in body
8646 assert "ssh://git@musehub.app/cloneowner/clone-widget-test.git" in body
8647 assert "https://musehub.app/cloneowner/clone-widget-test.git" in body
8648 # SSR clone widget DOM elements
8649 assert "clone-input" in body
8650 assert "clone-row" in body
8651 async def test_explore_page_returns_200(
8652 client: AsyncClient,
8653 ) -> None:
8654 """GET /explore returns 200 without authentication."""
8655 response = await client.get("/explore")
8656 assert response.status_code == 200
8657
8658
8659 @pytest.mark.anyio
8660 async def test_explore_page_has_filter_sidebar(
8661 client: AsyncClient,
8662 ) -> None:
8663 """Explore page renders a filter sidebar with sort, license, and clear-filters sections."""
8664 response = await client.get("/explore")
8665 assert response.status_code == 200
8666 body = response.text
8667 assert "explore-sidebar" in body
8668 assert "Clear filters" in body
8669 assert "Sort by" in body
8670 assert "License" in body
8671
8672
8673 @pytest.mark.anyio
8674 async def test_explore_page_has_sort_options(
8675 client: AsyncClient,
8676 ) -> None:
8677 """Explore page sidebar includes all four sort radio options."""
8678 response = await client.get("/explore")
8679 assert response.status_code == 200
8680 body = response.text
8681 assert "Most starred" in body
8682 assert "Recently updated" in body
8683 assert "Most forked" in body
8684 assert "Trending" in body
8685
8686
8687 @pytest.mark.anyio
8688 async def test_explore_page_has_license_options(
8689 client: AsyncClient,
8690 ) -> None:
8691 """Explore page sidebar includes the expected license filter options."""
8692 response = await client.get("/explore")
8693 assert response.status_code == 200
8694 body = response.text
8695 assert "CC0" in body
8696 assert "CC BY" in body
8697 assert "CC BY-SA" in body
8698 assert "CC BY-NC" in body
8699 assert "All Rights Reserved" in body
8700
8701
8702 @pytest.mark.anyio
8703 async def test_explore_page_has_repo_grid(
8704 client: AsyncClient,
8705 ) -> None:
8706 """Explore page includes the repo grid and JS discover API loader."""
8707 response = await client.get("/explore")
8708 assert response.status_code == 200
8709 body = response.text
8710 assert "repo-grid" in body
8711 assert "filter-form" in body
8712
8713
8714 @pytest.mark.anyio
8715 async def test_explore_page_has_audio_preview_js(
8716 client: AsyncClient,
8717 ) -> None:
8718 """Explore page renders the filter sidebar and repo grid (SSR, no inline audio-preview JS)."""
8719 response = await client.get("/explore")
8720 assert response.status_code == 200
8721 body = response.text
8722 assert "filter-form" in body
8723 assert "explore-layout" in body
8724 assert "repo-grid" in body
8725
8726
8727 @pytest.mark.anyio
8728 async def test_explore_page_default_sort_stars(
8729 client: AsyncClient,
8730 ) -> None:
8731 """Explore page defaults to 'stars' sort when no sort param given."""
8732 response = await client.get("/explore")
8733 assert response.status_code == 200
8734 body = response.text
8735 # 'stars' radio should be pre-checked (default sort)
8736 assert 'value="stars"' in body
8737 assert 'checked' in body
8738
8739
8740 @pytest.mark.anyio
8741 async def test_explore_page_sort_param_honoured(
8742 client: AsyncClient,
8743 ) -> None:
8744 """Explore page honours the ?sort= query param for pre-selecting a sort option."""
8745 response = await client.get("/explore?sort=updated")
8746 assert response.status_code == 200
8747 body = response.text
8748 assert 'value="updated"' in body
8749
8750
8751 @pytest.mark.anyio
8752 async def test_explore_page_no_auth_required(
8753 client: AsyncClient,
8754 ) -> None:
8755 """Explore page is publicly accessible — no JWT required (zero-friction discovery)."""
8756 response = await client.get("/explore")
8757 assert response.status_code == 200
8758 assert response.status_code != 401
8759 assert response.status_code != 403
8760
8761
8762 @pytest.mark.anyio
8763 async def test_explore_page_chip_toggle_js(
8764 client: AsyncClient,
8765 ) -> None:
8766 """Explore page includes toggleChip JS for progressive chip filter enhancement."""
8767 response = await client.get("/explore")
8768 assert response.status_code == 200
8769 body = response.text
8770 assert "toggleChip" in body
8771 # filter-chip only renders when repos with tags/languages exist; check explore structure
8772 assert "explore" in body.lower()
8773
8774
8775 @pytest.mark.anyio
8776 async def test_explore_page_get_params_preserved(
8777 client: AsyncClient,
8778 ) -> None:
8779 """Explore page accepts lang, license, topic, sort GET params without error."""
8780 response = await client.get(
8781 "/explore?lang=piano&license=CC0&topic=jazz&sort=stars"
8782 )
8783 assert response.status_code == 200