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