gabriel / musehub public
test_musehub_ui.py python
8604 lines 291.4 KB
8ac588de fix: rewrite session detail tests to match SSR template 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.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
2374 @pytest.mark.anyio
2375 async def test_profile_page_has_forked_section_js(
2376 client: AsyncClient,
2377 db_session: AsyncSession,
2378 ) -> None:
2379 """Profile HTML page includes the forked repos JS (loadForkedRepos, forked-section)."""
2380 await _make_profile(db_session, "jsforkuser")
2381 response = await client.get("/musehub/ui/users/jsforkuser")
2382 assert response.status_code == 200
2383 body = response.text
2384 assert "loadForkedRepos" in body
2385 assert "forked-section" in body
2386 assert "API_FORKS" in body
2387 assert "forked from" in body
2388
2389
2390 # ---------------------------------------------------------------------------
2391 # Starred repos tab
2392 # ---------------------------------------------------------------------------
2393
2394
2395 @pytest.mark.anyio
2396 async def test_profile_starred_repos_empty_list(
2397 client: AsyncClient,
2398 db_session: AsyncSession,
2399 ) -> None:
2400 """GET /api/v1/musehub/users/{username}/starred returns empty list when user has no stars."""
2401 await _make_profile(db_session, "freshstaruser")
2402 response = await client.get("/api/v1/musehub/users/freshstaruser/starred")
2403 assert response.status_code == 200
2404 data = response.json()
2405 assert data["starred"] == []
2406 assert data["total"] == 0
2407
2408
2409 @pytest.mark.anyio
2410 async def test_profile_starred_repos_returns_starred(
2411 client: AsyncClient,
2412 db_session: AsyncSession,
2413 ) -> None:
2414 """GET /api/v1/musehub/users/{username}/starred returns starred repos with full metadata."""
2415 await _make_profile(db_session, "stargazeruser")
2416
2417 repo = MusehubRepo(
2418 name="awesome-groove",
2419 owner="someartist",
2420 slug="awesome-groove",
2421 visibility="public",
2422 owner_user_id="someartist-id",
2423 description="A great groove",
2424 key_signature="C major",
2425 tempo_bpm=120,
2426 )
2427 db_session.add(repo)
2428 await db_session.commit()
2429 await db_session.refresh(repo)
2430
2431 star = MusehubStar(
2432 repo_id=repo.repo_id,
2433 user_id=_TEST_USER_ID,
2434 )
2435 db_session.add(star)
2436 await db_session.commit()
2437
2438 response = await client.get("/api/v1/musehub/users/stargazeruser/starred")
2439 assert response.status_code == 200
2440 data = response.json()
2441 assert data["total"] == 1
2442 assert len(data["starred"]) == 1
2443 entry = data["starred"][0]
2444 assert entry["repo"]["owner"] == "someartist"
2445 assert entry["repo"]["slug"] == "awesome-groove"
2446 assert entry["repo"]["description"] == "A great groove"
2447 assert "starId" in entry
2448 assert "starredAt" in entry
2449
2450
2451 @pytest.mark.anyio
2452 async def test_profile_starred_repos_404_for_unknown_user(
2453 client: AsyncClient,
2454 db_session: AsyncSession,
2455 ) -> None:
2456 """GET /api/v1/musehub/users/{unknown}/starred returns 404 when user doesn't exist."""
2457 response = await client.get("/api/v1/musehub/users/ghost-no-star-profile/starred")
2458 assert response.status_code == 404
2459
2460
2461 @pytest.mark.anyio
2462 async def test_profile_starred_repos_no_auth_required(
2463 client: AsyncClient,
2464 db_session: AsyncSession,
2465 ) -> None:
2466 """GET /api/v1/musehub/users/{username}/starred is publicly accessible without a JWT."""
2467 await _make_profile(db_session, "public-staruser")
2468 response = await client.get("/api/v1/musehub/users/public-staruser/starred")
2469 assert response.status_code == 200
2470 assert response.status_code != 401
2471
2472
2473 @pytest.mark.anyio
2474 async def test_profile_starred_repos_ordered_newest_first(
2475 client: AsyncClient,
2476 db_session: AsyncSession,
2477 ) -> None:
2478 """GET /api/v1/musehub/users/{username}/starred returns stars newest first."""
2479 from datetime import timezone
2480
2481 await _make_profile(db_session, "multistaruser")
2482
2483 repo_a = MusehubRepo(
2484 name="track-alpha", owner="artist-a", slug="track-alpha",
2485 visibility="public", owner_user_id="artist-a-id",
2486 )
2487 repo_b = MusehubRepo(
2488 name="track-beta", owner="artist-b", slug="track-beta",
2489 visibility="public", owner_user_id="artist-b-id",
2490 )
2491 db_session.add_all([repo_a, repo_b])
2492 await db_session.commit()
2493 await db_session.refresh(repo_a)
2494 await db_session.refresh(repo_b)
2495
2496 import datetime as dt
2497 star_a = MusehubStar(
2498 repo_id=repo_a.repo_id,
2499 user_id=_TEST_USER_ID,
2500 created_at=dt.datetime(2024, 1, 1, tzinfo=timezone.utc),
2501 )
2502 star_b = MusehubStar(
2503 repo_id=repo_b.repo_id,
2504 user_id=_TEST_USER_ID,
2505 created_at=dt.datetime(2024, 6, 1, tzinfo=timezone.utc),
2506 )
2507 db_session.add_all([star_a, star_b])
2508 await db_session.commit()
2509
2510 response = await client.get("/api/v1/musehub/users/multistaruser/starred")
2511 assert response.status_code == 200
2512 data = response.json()
2513 assert data["total"] == 2
2514 # newest first: star_b (June) before star_a (January)
2515 assert data["starred"][0]["repo"]["slug"] == "track-beta"
2516 assert data["starred"][1]["repo"]["slug"] == "track-alpha"
2517
2518
2519 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
2520 @pytest.mark.anyio
2521 async def test_profile_page_has_starred_section_js(
2522 client: AsyncClient,
2523 db_session: AsyncSession,
2524 ) -> None:
2525 """Profile HTML page includes the starred repos JS (loadStarredRepos, starred-section)."""
2526 await _make_profile(db_session, "jsstaruser")
2527 response = await client.get("/musehub/ui/users/jsstaruser")
2528 assert response.status_code == 200
2529 body = response.text
2530 assert "loadStarredRepos" in body
2531 assert "starred-section" in body
2532 assert "API_STARRED" in body
2533 assert "starredRepoCardHtml" in body
2534
2535
2536 # ---------------------------------------------------------------------------
2537 # Watched repos tab
2538 # ---------------------------------------------------------------------------
2539
2540
2541 @pytest.mark.anyio
2542 async def test_profile_watched_repos_empty_list(
2543 client: AsyncClient,
2544 db_session: AsyncSession,
2545 ) -> None:
2546 """GET /api/v1/musehub/users/{username}/watched returns empty list when user watches nothing."""
2547 await _make_profile(db_session, "freshwatchuser")
2548 response = await client.get("/api/v1/musehub/users/freshwatchuser/watched")
2549 assert response.status_code == 200
2550 data = response.json()
2551 assert data["watched"] == []
2552 assert data["total"] == 0
2553
2554
2555 @pytest.mark.anyio
2556 async def test_profile_watched_repos_returns_watched(
2557 client: AsyncClient,
2558 db_session: AsyncSession,
2559 ) -> None:
2560 """GET /api/v1/musehub/users/{username}/watched returns watched repos with full metadata."""
2561 await _make_profile(db_session, "watcheruser")
2562
2563 repo = MusehubRepo(
2564 name="cool-composition",
2565 owner="composer",
2566 slug="cool-composition",
2567 visibility="public",
2568 owner_user_id="composer-id",
2569 description="A cool composition",
2570 key_signature="G minor",
2571 tempo_bpm=90,
2572 )
2573 db_session.add(repo)
2574 await db_session.commit()
2575 await db_session.refresh(repo)
2576
2577 watch = MusehubWatch(
2578 repo_id=repo.repo_id,
2579 user_id=_TEST_USER_ID,
2580 )
2581 db_session.add(watch)
2582 await db_session.commit()
2583
2584 response = await client.get("/api/v1/musehub/users/watcheruser/watched")
2585 assert response.status_code == 200
2586 data = response.json()
2587 assert data["total"] == 1
2588 assert len(data["watched"]) == 1
2589 entry = data["watched"][0]
2590 assert entry["repo"]["owner"] == "composer"
2591 assert entry["repo"]["slug"] == "cool-composition"
2592 assert entry["repo"]["description"] == "A cool composition"
2593 assert "watchId" in entry
2594 assert "watchedAt" in entry
2595
2596
2597 @pytest.mark.anyio
2598 async def test_profile_watched_repos_404_for_unknown_user(
2599 client: AsyncClient,
2600 db_session: AsyncSession,
2601 ) -> None:
2602 """GET /api/v1/musehub/users/{unknown}/watched returns 404 when user doesn't exist."""
2603 response = await client.get("/api/v1/musehub/users/ghost-no-watch-profile/watched")
2604 assert response.status_code == 404
2605
2606
2607 @pytest.mark.anyio
2608 async def test_profile_watched_repos_no_auth_required(
2609 client: AsyncClient,
2610 db_session: AsyncSession,
2611 ) -> None:
2612 """GET /api/v1/musehub/users/{username}/watched is publicly accessible without a JWT."""
2613 await _make_profile(db_session, "public-watchuser")
2614 response = await client.get("/api/v1/musehub/users/public-watchuser/watched")
2615 assert response.status_code == 200
2616 assert response.status_code != 401
2617
2618
2619 @pytest.mark.anyio
2620 async def test_profile_watched_repos_ordered_newest_first(
2621 client: AsyncClient,
2622 db_session: AsyncSession,
2623 ) -> None:
2624 """GET /api/v1/musehub/users/{username}/watched returns watches newest first."""
2625 import datetime as dt
2626 from datetime import timezone
2627
2628 await _make_profile(db_session, "multiwatchuser")
2629
2630 repo_a = MusehubRepo(
2631 name="song-alpha", owner="band-a", slug="song-alpha",
2632 visibility="public", owner_user_id="band-a-id",
2633 )
2634 repo_b = MusehubRepo(
2635 name="song-beta", owner="band-b", slug="song-beta",
2636 visibility="public", owner_user_id="band-b-id",
2637 )
2638 db_session.add_all([repo_a, repo_b])
2639 await db_session.commit()
2640 await db_session.refresh(repo_a)
2641 await db_session.refresh(repo_b)
2642
2643 watch_a = MusehubWatch(
2644 repo_id=repo_a.repo_id,
2645 user_id=_TEST_USER_ID,
2646 created_at=dt.datetime(2024, 1, 1, tzinfo=timezone.utc),
2647 )
2648 watch_b = MusehubWatch(
2649 repo_id=repo_b.repo_id,
2650 user_id=_TEST_USER_ID,
2651 created_at=dt.datetime(2024, 6, 1, tzinfo=timezone.utc),
2652 )
2653 db_session.add_all([watch_a, watch_b])
2654 await db_session.commit()
2655
2656 response = await client.get("/api/v1/musehub/users/multiwatchuser/watched")
2657 assert response.status_code == 200
2658 data = response.json()
2659 assert data["total"] == 2
2660 # newest first: watch_b (June) before watch_a (January)
2661 assert data["watched"][0]["repo"]["slug"] == "song-beta"
2662 assert data["watched"][1]["repo"]["slug"] == "song-alpha"
2663
2664
2665 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
2666 @pytest.mark.anyio
2667 async def test_profile_page_has_watched_section_js(
2668 client: AsyncClient,
2669 db_session: AsyncSession,
2670 ) -> None:
2671 """Profile HTML page includes the watched repos JS (loadWatchedRepos, watched-section)."""
2672 await _make_profile(db_session, "jswatchuser")
2673 response = await client.get("/musehub/ui/users/jswatchuser")
2674 assert response.status_code == 200
2675 body = response.text
2676 assert "loadWatchedRepos" in body
2677 assert "watched-section" in body
2678 assert "API_WATCHED" in body
2679
2680
2681 @pytest.mark.anyio
2682 async def test_timeline_page_renders(
2683 client: AsyncClient,
2684 db_session: AsyncSession,
2685 ) -> None:
2686 """GET /musehub/ui/{repo_id}/timeline returns 200 HTML without requiring a JWT."""
2687 repo_id = await _make_repo(db_session)
2688 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2689 assert response.status_code == 200
2690 assert "text/html" in response.headers["content-type"]
2691 body = response.text
2692 assert "Muse Hub" in body
2693 assert "timeline" in body.lower()
2694 assert repo_id[:8] in body
2695
2696
2697 @pytest.mark.anyio
2698 async def test_timeline_page_no_auth_required(
2699 client: AsyncClient,
2700 db_session: AsyncSession,
2701 ) -> None:
2702 """Timeline UI route must be accessible without an Authorization header."""
2703 repo_id = await _make_repo(db_session)
2704 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2705 assert response.status_code != 401
2706 assert response.status_code == 200
2707
2708
2709 @pytest.mark.anyio
2710 async def test_timeline_page_contains_layer_controls(
2711 client: AsyncClient,
2712 db_session: AsyncSession,
2713 ) -> None:
2714 """Timeline page embeds toggleable layer controls for all four layers."""
2715 repo_id = await _make_repo(db_session)
2716 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2717 assert response.status_code == 200
2718 body = response.text
2719 assert "Commits" in body
2720 assert "Emotion" in body
2721 assert "Sections" in body
2722 assert "Tracks" in body
2723
2724
2725 @pytest.mark.anyio
2726 async def test_timeline_page_contains_zoom_controls(
2727 client: AsyncClient,
2728 db_session: AsyncSession,
2729 ) -> None:
2730 """Timeline page embeds day/week/month/all zoom buttons."""
2731 repo_id = await _make_repo(db_session)
2732 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2733 assert response.status_code == 200
2734 body = response.text
2735 assert "Day" in body
2736 assert "Week" in body
2737 assert "Month" in body
2738 assert "All" in body
2739
2740
2741 @pytest.mark.anyio
2742 async def test_timeline_page_includes_token_form(
2743 client: AsyncClient,
2744 db_session: AsyncSession,
2745 ) -> None:
2746 """Timeline page includes the JWT token input form."""
2747 repo_id = await _make_repo(db_session)
2748 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2749 assert response.status_code == 200
2750 body = response.text
2751 assert "musehub/static/musehub.js" in body
2752 assert "token-form" in body
2753
2754
2755 @pytest.mark.anyio
2756 async def test_timeline_page_contains_overlay_toggles(
2757 client: AsyncClient,
2758 db_session: AsyncSession,
2759 ) -> None:
2760 """Timeline page must include Sessions, PRs, and Releases layer toggle checkboxes.
2761
2762 Regression test — before this fix the timeline had no
2763 overlay markers for repo lifecycle events (sessions, PR merges, releases).
2764 """
2765 await _make_repo(db_session)
2766 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2767 assert response.status_code == 200
2768 body = response.text
2769 # All three new overlay toggle labels must be present.
2770 assert "Sessions" in body
2771 assert "PRs" in body
2772 assert "Releases" in body
2773
2774
2775 @pytest.mark.anyio
2776 async def test_timeline_page_overlay_js_variables(
2777 client: AsyncClient,
2778 db_session: AsyncSession,
2779 ) -> None:
2780 """Timeline page script must initialise sessions, mergedPRs, and releases arrays.
2781
2782 Verifies that the overlay data arrays and the layer toggle state are wired
2783 up in the page's inline script so the renderer can draw markers.
2784 """
2785 await _make_repo(db_session)
2786 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2787 assert response.status_code == 200
2788 body = response.text
2789 # State variables for overlay data must be declared.
2790 assert "let sessions" in body
2791 assert "let mergedPRs" in body
2792 assert "let releases" in body
2793 # Layer toggle state must include the three new keys.
2794 assert "sessions: true" in body
2795 assert "prs: true" in body
2796 assert "releases: true" in body
2797
2798
2799 @pytest.mark.anyio
2800 async def test_timeline_page_overlay_fetch_calls(
2801 client: AsyncClient,
2802 db_session: AsyncSession,
2803 ) -> None:
2804 """Timeline page must issue API calls for sessions, merged PRs, and releases.
2805
2806 Checks that the inline script calls the correct API paths so the browser
2807 will fetch overlay data when the page loads.
2808 """
2809 await _make_repo(db_session)
2810 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2811 assert response.status_code == 200
2812 body = response.text
2813 assert "/sessions" in body
2814 assert "state=merged" in body
2815 assert "/releases" in body
2816
2817
2818 @pytest.mark.anyio
2819 async def test_timeline_page_overlay_legend(
2820 client: AsyncClient,
2821 db_session: AsyncSession,
2822 ) -> None:
2823 """Timeline page legend must describe the three new overlay marker types."""
2824 await _make_repo(db_session)
2825 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2826 assert response.status_code == 200
2827 body = response.text
2828 # Colour labels in the legend.
2829 assert "teal" in body.lower()
2830 assert "gold" in body.lower()
2831
2832
2833 @pytest.mark.anyio
2834 async def test_timeline_pr_markers_use_merged_at_for_positioning(
2835 client: AsyncClient,
2836 db_session: AsyncSession,
2837 ) -> None:
2838 """Timeline JS must use pr.mergedAt (falling back to pr.createdAt) for marker X position.
2839
2840 Regression test: previously the overlay always used
2841 createdAt, which positioned merge markers at PR *open* time instead of
2842 merge time, sometimes off by days or weeks.
2843 """
2844 await _make_repo(db_session)
2845 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2846 assert response.status_code == 200
2847 body = response.text
2848 # The fix: JS should reference pr.mergedAt and fall back to pr.createdAt.
2849 assert "pr.mergedAt" in body
2850 assert "pr.createdAt" in body
2851 # The tooltip timestamp must also use mergedAt.
2852 assert "new Date(pr.mergedAt)" in body
2853
2854
2855 @pytest.mark.anyio
2856 async def test_pr_response_includes_merged_at_after_merge(
2857 client: AsyncClient,
2858 db_session: AsyncSession,
2859 auth_headers: dict[str, str],
2860 ) -> None:
2861 """PRResponse must expose merged_at set to the merge timestamp (not None) after merge.
2862
2863 Regression test: before this fix merged_at was absent from
2864 PRResponse, forcing the timeline to fall back to createdAt.
2865 """
2866 from datetime import datetime, timezone
2867
2868 from musehub.services import musehub_pull_requests, musehub_repository
2869
2870 repo_id = await _make_repo(db_session)
2871
2872 # Create two branches with commits so the merge can proceed.
2873 import uuid as _uuid
2874
2875 from musehub.db import musehub_models as dbm
2876
2877 commit_a_id = _uuid.uuid4().hex
2878 commit_main_id = _uuid.uuid4().hex
2879
2880 commit_a = dbm.MusehubCommit(
2881 commit_id=commit_a_id,
2882 repo_id=repo_id,
2883 branch="feat/test-merge",
2884 parent_ids=[],
2885 message="test commit on feature branch",
2886 author="tester",
2887 timestamp=datetime.now(timezone.utc),
2888 )
2889 branch_a = dbm.MusehubBranch(
2890 repo_id=repo_id, name="feat/test-merge", head_commit_id=commit_a_id
2891 )
2892 db_session.add(commit_a)
2893 db_session.add(branch_a)
2894
2895 commit_main = dbm.MusehubCommit(
2896 commit_id=commit_main_id,
2897 repo_id=repo_id,
2898 branch="main",
2899 parent_ids=[],
2900 message="initial commit on main",
2901 author="tester",
2902 timestamp=datetime.now(timezone.utc),
2903 )
2904 branch_main = dbm.MusehubBranch(
2905 repo_id=repo_id, name="main", head_commit_id=commit_main_id
2906 )
2907 db_session.add(commit_main)
2908 db_session.add(branch_main)
2909 await db_session.flush()
2910
2911 pr = await musehub_pull_requests.create_pr(
2912 db_session,
2913 repo_id=repo_id,
2914 title="Test merge PR",
2915 from_branch="feat/test-merge",
2916 to_branch="main",
2917 body="",
2918 author="tester",
2919 )
2920 await db_session.flush()
2921
2922 before_merge = datetime.now(timezone.utc)
2923 merged_pr = await musehub_pull_requests.merge_pr(
2924 db_session, repo_id, pr.pr_id, merge_strategy="merge_commit"
2925 )
2926 after_merge = datetime.now(timezone.utc)
2927
2928 assert merged_pr.merged_at is not None, "merged_at must be set after merge"
2929 # merged_at must be a timezone-aware datetime between before and after the merge call.
2930 merged_at = merged_pr.merged_at
2931 if merged_at.tzinfo is None:
2932 merged_at = merged_at.replace(tzinfo=timezone.utc)
2933 assert before_merge <= merged_at <= after_merge, (
2934 f"merged_at {merged_at} is outside the expected range [{before_merge}, {after_merge}]"
2935 )
2936 assert merged_pr.state == "merged"
2937
2938
2939 # ---------------------------------------------------------------------------
2940 # Embed player route tests
2941 # ---------------------------------------------------------------------------
2942
2943
2944 _UTC = timezone.utc
2945
2946
2947 @pytest.mark.anyio
2948 async def test_graph_page_contains_dag_js(
2949 client: AsyncClient,
2950 db_session: AsyncSession,
2951 ) -> None:
2952 """Graph page embeds the client-side DAG renderer JavaScript."""
2953 repo_id = await _make_repo(db_session)
2954 response = await client.get("/musehub/ui/testuser/test-beats/graph")
2955 assert response.status_code == 200
2956 body = response.text
2957 assert "renderGraph" in body
2958 assert "dag-viewport" in body
2959 assert "dag-svg" in body
2960
2961
2962 @pytest.mark.anyio
2963 async def test_graph_page_contains_session_ring_js(
2964 client: AsyncClient,
2965 db_session: AsyncSession,
2966 ) -> None:
2967 """Graph page embeds session-marker and reaction-count JavaScript.
2968
2969 Regression guard: ensures the template contains the three
2970 new client-side components added by this feature — SESSION_RING_COLOR (the
2971 teal ring constant), buildSessionMap (commit→session index builder), and
2972 fetchReactions (batch reaction fetcher). A missing symbol means the graph
2973 will silently render with no session markers or reaction counts.
2974 """
2975 await _make_repo(db_session)
2976 response = await client.get("/musehub/ui/testuser/test-beats/graph")
2977 assert response.status_code == 200
2978 body = response.text
2979 assert "SESSION_RING_COLOR" in body, "Teal session ring constant missing from graph page"
2980 assert "buildSessionMap" in body, "buildSessionMap function missing from graph page"
2981 assert "fetchReactions" in body, "fetchReactions function missing from graph page"
2982
2983
2984 @pytest.mark.anyio
2985 async def test_session_list_page_returns_200(
2986 client: AsyncClient,
2987 db_session: AsyncSession,
2988 ) -> None:
2989 """GET /musehub/ui/{repo_id}/sessions returns 200 HTML without requiring a JWT."""
2990 repo_id = await _make_repo(db_session)
2991 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
2992 assert response.status_code == 200
2993 assert "text/html" in response.headers["content-type"]
2994 body = response.text
2995 assert "Muse Hub" in body
2996 assert "Sessions" in body
2997 assert "musehub/static/musehub.js" in body
2998
2999
3000 @pytest.mark.anyio
3001 async def test_session_detail_renders(
3002 client: AsyncClient,
3003 db_session: AsyncSession,
3004 ) -> None:
3005 """GET /musehub/ui/{owner}/{repo_slug}/sessions/{session_id} returns 200 HTML."""
3006 repo_id = await _make_repo(db_session)
3007 session_id = await _make_session(db_session, repo_id)
3008 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3009 assert response.status_code == 200
3010 assert "text/html" in response.headers["content-type"]
3011 body = response.text
3012 assert "Muse Hub" in body
3013 assert "Session" in body
3014 assert session_id[:8] in body
3015
3016
3017 @pytest.mark.anyio
3018 async def test_session_detail_participants(
3019 client: AsyncClient,
3020 db_session: AsyncSession,
3021 ) -> None:
3022 """Session detail page renders the Participants sidebar section."""
3023 repo_id = await _make_repo(db_session)
3024 session_id = await _make_session(db_session, repo_id, participants=["alice", "bob"])
3025 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3026 assert response.status_code == 200
3027 body = response.text
3028 assert "Participants" in body
3029 assert "alice" in body
3030
3031
3032 @pytest.mark.anyio
3033 async def test_session_detail_commits(
3034 client: AsyncClient,
3035 db_session: AsyncSession,
3036 ) -> None:
3037 """Session detail page renders commit pills when commits are present."""
3038 repo_id = await _make_repo(db_session)
3039 session_id = await _make_session(
3040 db_session, repo_id, commits=["abc1234567890", "def9876543210"]
3041 )
3042 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3043 assert response.status_code == 200
3044 body = response.text
3045 assert "Commits" in body
3046 assert "commit-pill" in body
3047
3048
3049 @pytest.mark.anyio
3050 async def test_session_detail_404_for_unknown_session(
3051 client: AsyncClient,
3052 db_session: AsyncSession,
3053 ) -> None:
3054 """Session detail route returns HTTP 404 for an unknown session ID.
3055
3056 The route is fully SSR — it performs a real DB lookup and returns 404
3057 rather than a JS shell that handles missing data client-side.
3058 """
3059 await _make_repo(db_session)
3060 response = await client.get("/musehub/ui/testuser/test-beats/sessions/does-not-exist-1234")
3061 assert response.status_code == 404
3062
3063
3064 @pytest.mark.anyio
3065 async def test_session_detail_shows_intent(
3066 client: AsyncClient,
3067 db_session: AsyncSession,
3068 ) -> None:
3069 """Session detail page renders the intent field when present."""
3070 repo_id = await _make_repo(db_session)
3071 session_id = await _make_session(db_session, repo_id, intent="ambient soundscape")
3072 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3073 assert response.status_code == 200
3074 assert "ambient soundscape" in response.text
3075
3076
3077 @pytest.mark.anyio
3078 async def test_session_detail_shows_location(
3079 client: AsyncClient,
3080 db_session: AsyncSession,
3081 ) -> None:
3082 """Session detail page renders the location field when present."""
3083 repo_id = await _make_repo(db_session)
3084 session_id = await _make_session(db_session, repo_id)
3085 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3086 assert response.status_code == 200
3087 assert "Studio A" in response.text
3088
3089
3090 @pytest.mark.anyio
3091 async def test_session_detail_shows_meta_labels(
3092 client: AsyncClient,
3093 db_session: AsyncSession,
3094 ) -> None:
3095 """Session detail page renders the meta-label / meta-value row layout."""
3096 repo_id = await _make_repo(db_session)
3097 session_id = await _make_session(db_session, repo_id)
3098 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3099 assert response.status_code == 200
3100 body = response.text
3101 assert "meta-label" in body
3102 assert "meta-value" in body
3103 assert "Started" in body
3104
3105
3106 @pytest.mark.anyio
3107 async def test_session_detail_active_shows_live_badge(
3108 client: AsyncClient,
3109 db_session: AsyncSession,
3110 ) -> None:
3111 """Session detail page shows a live badge for an active session."""
3112 repo_id = await _make_repo(db_session)
3113 session_id = await _make_session(db_session, repo_id, is_active=True)
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 "live" in body
3118 assert "session-live-dot" in body
3119
3120
3121 @pytest.mark.anyio
3122 async def test_session_detail_ended_shows_badge(
3123 client: AsyncClient,
3124 db_session: AsyncSession,
3125 ) -> None:
3126 """Session detail page shows 'ended' badge for a completed session."""
3127 repo_id = await _make_repo(db_session)
3128 session_id = await _make_session(db_session, repo_id, is_active=False)
3129 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3130 assert response.status_code == 200
3131 assert "ended" in response.text
3132
3133
3134 @pytest.mark.anyio
3135 async def test_session_detail_shows_notes(
3136 client: AsyncClient,
3137 db_session: AsyncSession,
3138 ) -> None:
3139 """Session detail page renders closing notes when present."""
3140 repo_id = await _make_repo(db_session)
3141 session_id = await _make_session(
3142 db_session, repo_id, notes="Great vibe, revisit the bridge section."
3143 )
3144 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3145 assert response.status_code == 200
3146 assert "Great vibe, revisit the bridge section." in response.text
3147
3148
3149 async def _make_session(
3150 db_session: AsyncSession,
3151 repo_id: str,
3152 *,
3153 started_offset_seconds: int = 0,
3154 is_active: bool = False,
3155 intent: str = "jazz composition",
3156 participants: list[str] | None = None,
3157 commits: list[str] | None = None,
3158 notes: str = "",
3159 ) -> str:
3160 """Seed a MusehubSession and return its session_id."""
3161 start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
3162 from datetime import timedelta
3163
3164 started_at = start + timedelta(seconds=started_offset_seconds)
3165 ended_at = None if is_active else started_at + timedelta(hours=1)
3166 row = MusehubSession(
3167 repo_id=repo_id,
3168 started_at=started_at,
3169 ended_at=ended_at,
3170 participants=participants or ["producer-a"],
3171 commits=commits or [],
3172 notes=notes,
3173 intent=intent,
3174 location="Studio A",
3175 is_active=is_active,
3176 )
3177 db_session.add(row)
3178 await db_session.commit()
3179 await db_session.refresh(row)
3180 return str(row.session_id)
3181
3182
3183 @pytest.mark.anyio
3184 async def test_sessions_json_response(
3185 client: AsyncClient,
3186 db_session: AsyncSession,
3187 auth_headers: dict[str, str],
3188 ) -> None:
3189 """GET /api/v1/musehub/repos/{repo_id}/sessions returns session list with metadata."""
3190 repo_id = await _make_repo(db_session)
3191 session_id = await _make_session(db_session, repo_id, intent="jazz solo")
3192
3193 response = await client.get(
3194 f"/api/v1/musehub/repos/{repo_id}/sessions",
3195 headers=auth_headers,
3196 )
3197 assert response.status_code == 200
3198 data = response.json()
3199 assert "sessions" in data
3200 assert "total" in data
3201 assert data["total"] == 1
3202 sess = data["sessions"][0]
3203 assert sess["sessionId"] == session_id
3204 assert sess["intent"] == "jazz solo"
3205 assert sess["location"] == "Studio A"
3206 assert sess["isActive"] is False
3207 assert sess["durationSeconds"] == pytest.approx(3600.0)
3208
3209
3210 @pytest.mark.anyio
3211 async def test_sessions_newest_first(
3212 client: AsyncClient,
3213 db_session: AsyncSession,
3214 auth_headers: dict[str, str],
3215 ) -> None:
3216 """Sessions are returned newest-first (active sessions appear before ended sessions)."""
3217 repo_id = await _make_repo(db_session)
3218 # older ended session
3219 await _make_session(db_session, repo_id, started_offset_seconds=0, intent="older")
3220 # newer ended session
3221 await _make_session(db_session, repo_id, started_offset_seconds=3600, intent="newer")
3222 # active session (should surface first regardless of time)
3223 await _make_session(
3224 db_session, repo_id, started_offset_seconds=100, is_active=True, intent="live"
3225 )
3226
3227 response = await client.get(
3228 f"/api/v1/musehub/repos/{repo_id}/sessions",
3229 headers=auth_headers,
3230 )
3231 assert response.status_code == 200
3232 sessions = response.json()["sessions"]
3233 assert len(sessions) == 3
3234 # Active session must come first
3235 assert sessions[0]["isActive"] is True
3236 assert sessions[0]["intent"] == "live"
3237 # Then newest ended session
3238 assert sessions[1]["intent"] == "newer"
3239 assert sessions[2]["intent"] == "older"
3240
3241
3242 @pytest.mark.anyio
3243 async def test_sessions_empty_for_new_repo(
3244 client: AsyncClient,
3245 db_session: AsyncSession,
3246 auth_headers: dict[str, str],
3247 ) -> None:
3248 """GET /api/v1/musehub/repos/{repo_id}/sessions returns empty list for new repo."""
3249 repo_id = await _make_repo(db_session)
3250 response = await client.get(
3251 f"/api/v1/musehub/repos/{repo_id}/sessions",
3252 headers=auth_headers,
3253 )
3254 assert response.status_code == 200
3255 data = response.json()
3256 assert data["sessions"] == []
3257 assert data["total"] == 0
3258
3259
3260 @pytest.mark.anyio
3261 async def test_sessions_requires_auth(
3262 client: AsyncClient,
3263 db_session: AsyncSession,
3264 ) -> None:
3265 """GET /api/v1/musehub/repos/{repo_id}/sessions returns 401 without auth."""
3266 repo_id = await _make_repo(db_session)
3267 response = await client.get(f"/api/v1/musehub/repos/{repo_id}/sessions")
3268 assert response.status_code == 401
3269
3270
3271 @pytest.mark.anyio
3272 async def test_sessions_404_for_unknown_repo(
3273 client: AsyncClient,
3274 db_session: AsyncSession,
3275 auth_headers: dict[str, str],
3276 ) -> None:
3277 """GET /api/v1/musehub/repos/{unknown}/sessions returns 404."""
3278 response = await client.get(
3279 "/api/v1/musehub/repos/does-not-exist/sessions",
3280 headers=auth_headers,
3281 )
3282 assert response.status_code == 404
3283
3284
3285 @pytest.mark.anyio
3286 async def test_create_session_returns_201(
3287 client: AsyncClient,
3288 db_session: AsyncSession,
3289 auth_headers: dict[str, str],
3290 ) -> None:
3291 """POST /api/v1/musehub/repos/{repo_id}/sessions creates a session and returns 201."""
3292 repo_id = await _make_repo(db_session)
3293 payload = {
3294 "participants": ["producer-a", "collab-b"],
3295 "intent": "house beat experiment",
3296 "location": "Remote – Berlin",
3297 "isActive": True,
3298 }
3299 response = await client.post(
3300 f"/api/v1/musehub/repos/{repo_id}/sessions",
3301 json=payload,
3302 headers=auth_headers,
3303 )
3304 assert response.status_code == 201
3305 data = response.json()
3306 assert data["isActive"] is True
3307 assert data["intent"] == "house beat experiment"
3308 assert data["location"] == "Remote \u2013 Berlin"
3309 assert data["participants"] == ["producer-a", "collab-b"]
3310 assert "sessionId" in data
3311
3312
3313 @pytest.mark.anyio
3314 async def test_stop_session_marks_ended(
3315 client: AsyncClient,
3316 db_session: AsyncSession,
3317 auth_headers: dict[str, str],
3318 ) -> None:
3319 """POST /api/v1/musehub/repos/{repo_id}/sessions/{session_id}/stop closes a live session."""
3320 repo_id = await _make_repo(db_session)
3321 session_id = await _make_session(db_session, repo_id, is_active=True)
3322
3323 response = await client.post(
3324 f"/api/v1/musehub/repos/{repo_id}/sessions/{session_id}/stop",
3325 json={},
3326 headers=auth_headers,
3327 )
3328 assert response.status_code == 200
3329 data = response.json()
3330 assert data["isActive"] is False
3331 assert data["endedAt"] is not None
3332 assert data["durationSeconds"] is not None
3333
3334
3335 @pytest.mark.anyio
3336 async def test_active_session_has_null_duration(
3337 client: AsyncClient,
3338 db_session: AsyncSession,
3339 auth_headers: dict[str, str],
3340 ) -> None:
3341 """Active sessions must have durationSeconds=null (session still in progress)."""
3342 repo_id = await _make_repo(db_session)
3343 await _make_session(db_session, repo_id, is_active=True)
3344
3345 response = await client.get(
3346 f"/api/v1/musehub/repos/{repo_id}/sessions",
3347 headers=auth_headers,
3348 )
3349 assert response.status_code == 200
3350 sess = response.json()["sessions"][0]
3351 assert sess["isActive"] is True
3352 assert sess["durationSeconds"] is None
3353
3354
3355 @pytest.mark.anyio
3356 async def test_session_response_includes_commits_and_notes(
3357 client: AsyncClient,
3358 db_session: AsyncSession,
3359 auth_headers: dict[str, str],
3360 ) -> None:
3361 """SessionResponse includes commits list and notes field in the JSON payload."""
3362 repo_id = await _make_repo(db_session)
3363 commit_ids = ["abc123", "def456", "ghi789"]
3364 closing_notes = "Great session, nailed the groove."
3365 await _make_session(
3366 db_session,
3367 repo_id,
3368 intent="funk groove",
3369 commits=commit_ids,
3370 notes=closing_notes,
3371 )
3372
3373 response = await client.get(
3374 f"/api/v1/musehub/repos/{repo_id}/sessions",
3375 headers=auth_headers,
3376 )
3377 assert response.status_code == 200
3378 sess = response.json()["sessions"][0]
3379 assert sess["commits"] == commit_ids
3380 assert sess["notes"] == closing_notes
3381
3382
3383 @pytest.mark.anyio
3384 async def test_session_response_commits_field_present(
3385 client: AsyncClient,
3386 db_session: AsyncSession,
3387 auth_headers: dict[str, str],
3388 ) -> None:
3389 """Sessions API response includes the 'commits' field for each session.
3390
3391 Regression guard: the graph page uses the session commits
3392 list to build the session→commit index (buildSessionMap). If this field
3393 is absent or empty when commits exist, no session rings will appear on
3394 the DAG graph.
3395 """
3396 repo_id = await _make_repo(db_session)
3397 commit_ids = ["abc123def456abc123def456abc123de", "feedbeeffeedbeefdead000000000001"]
3398 row = MusehubSession(
3399 repo_id=repo_id,
3400 started_at=datetime(2025, 3, 1, 10, 0, 0, tzinfo=timezone.utc),
3401 ended_at=datetime(2025, 3, 1, 11, 0, 0, tzinfo=timezone.utc),
3402 participants=["artist-a"],
3403 intent="session with commits",
3404 location="Studio B",
3405 is_active=False,
3406 commits=commit_ids,
3407 )
3408 db_session.add(row)
3409 await db_session.commit()
3410 await db_session.refresh(row)
3411
3412 response = await client.get(
3413 f"/api/v1/musehub/repos/{repo_id}/sessions",
3414 headers=auth_headers,
3415 )
3416 assert response.status_code == 200
3417 sessions = response.json()["sessions"]
3418 assert len(sessions) == 1
3419 sess = sessions[0]
3420 assert "commits" in sess, "'commits' field missing from SessionResponse"
3421 assert sess["commits"] == commit_ids, "commits field does not match seeded commit IDs"
3422
3423
3424 @pytest.mark.anyio
3425 async def test_session_response_empty_commits_and_notes_defaults(
3426 client: AsyncClient,
3427 db_session: AsyncSession,
3428 auth_headers: dict[str, str],
3429 ) -> None:
3430 """SessionResponse defaults commits to [] and notes to '' when absent."""
3431 repo_id = await _make_repo(db_session)
3432 await _make_session(db_session, repo_id, intent="defaults check")
3433
3434 response = await client.get(
3435 f"/api/v1/musehub/repos/{repo_id}/sessions",
3436 headers=auth_headers,
3437 )
3438 assert response.status_code == 200
3439 sess = response.json()["sessions"][0]
3440 assert sess["commits"] == []
3441 assert sess["notes"] == ""
3442
3443
3444 @pytest.mark.anyio
3445 async def test_session_list_page_contains_avatar_markup(
3446 client: AsyncClient,
3447 db_session: AsyncSession,
3448 ) -> None:
3449 """Sessions list page HTML contains participant avatar JS and CSS class references."""
3450 repo_id = await _make_repo(db_session)
3451 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3452 assert response.status_code == 200
3453 body = response.text
3454 # The JS helper that builds avatar stacks must be present in the page
3455 assert "participant-stack" in body
3456 assert "participant-avatar" in body
3457 assert "strHsl" in body
3458
3459
3460 @pytest.mark.anyio
3461 async def test_session_list_page_contains_commit_pill_markup(
3462 client: AsyncClient,
3463 db_session: AsyncSession,
3464 ) -> None:
3465 """Sessions list page HTML contains commit count pill JS reference."""
3466 repo_id = await _make_repo(db_session)
3467 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3468 assert response.status_code == 200
3469 body = response.text
3470 assert "session-commit-pill" in body
3471
3472
3473 @pytest.mark.anyio
3474 async def test_session_list_page_contains_live_indicator_markup(
3475 client: AsyncClient,
3476 db_session: AsyncSession,
3477 ) -> None:
3478 """Sessions list page HTML contains pulsing LIVE indicator JS reference."""
3479 repo_id = await _make_repo(db_session)
3480 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3481 assert response.status_code == 200
3482 body = response.text
3483 assert "session-live-pulse" in body
3484
3485
3486 @pytest.mark.anyio
3487 async def test_session_list_page_contains_notes_preview_markup(
3488 client: AsyncClient,
3489 db_session: AsyncSession,
3490 ) -> None:
3491 """Sessions list page HTML contains notes preview JS reference."""
3492 repo_id = await _make_repo(db_session)
3493 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3494 assert response.status_code == 200
3495 body = response.text
3496 assert "session-notes-preview" in body
3497 assert "notesPreview" in body
3498
3499
3500 @pytest.mark.anyio
3501 async def test_session_list_page_contains_location_tag_markup(
3502 client: AsyncClient,
3503 db_session: AsyncSession,
3504 ) -> None:
3505 """Sessions list page HTML contains location tag JS reference."""
3506 repo_id = await _make_repo(db_session)
3507 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3508 assert response.status_code == 200
3509 body = response.text
3510 assert "session-location-tag" in body
3511
3512
3513 async def test_contour_page_renders(
3514 client: AsyncClient,
3515 db_session: AsyncSession,
3516 ) -> None:
3517 """GET /musehub/ui/{repo_id}/analysis/{ref}/contour returns 200 HTML."""
3518 repo_id = await _make_repo(db_session)
3519 ref = "abc1234567890abcdef"
3520 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/contour")
3521 assert response.status_code == 200
3522 assert "text/html" in response.headers["content-type"]
3523
3524
3525 @pytest.mark.anyio
3526 async def test_contour_page_no_auth_required(
3527 client: AsyncClient,
3528 db_session: AsyncSession,
3529 ) -> None:
3530 """Contour analysis page must be accessible without a JWT (HTML shell handles auth)."""
3531 repo_id = await _make_repo(db_session)
3532 ref = "deadbeef1234"
3533 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/contour")
3534 assert response.status_code != 401
3535 assert response.status_code == 200
3536
3537
3538 @pytest.mark.anyio
3539 async def test_contour_page_contains_graph_ui(
3540 client: AsyncClient,
3541 db_session: AsyncSession,
3542 ) -> None:
3543 """Contour page SSR: must contain pitch-curve polyline, shape summary, and direction data."""
3544 repo_id = await _make_repo(db_session)
3545 ref = "cafebabe12345678"
3546 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/contour")
3547 assert response.status_code == 200
3548 body = response.text
3549 assert "Melodic Contour" in body
3550 assert "<polyline" in body or "PITCH CURVE" in body
3551 assert "Shape" in body
3552 assert "Overall Direction" in body
3553 assert repo_id in body
3554
3555
3556 @pytest.mark.anyio
3557 async def test_contour_json_response(
3558 client: AsyncClient,
3559 auth_headers: dict[str, str],
3560 db_session: AsyncSession,
3561 ) -> None:
3562 """GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/contour returns ContourData.
3563
3564 Verifies that the JSON response includes shape classification labels and
3565 the pitch_curve array that the contour page visualises.
3566 """
3567 resp = await client.post(
3568 "/api/v1/musehub/repos",
3569 json={"name": "contour-test-repo", "owner": "testuser", "visibility": "private"},
3570 headers=auth_headers,
3571 )
3572 assert resp.status_code == 201
3573 repo_id = resp.json()["repoId"]
3574
3575 resp = await client.get(
3576 f"/api/v1/musehub/repos/{repo_id}/analysis/main/contour",
3577 headers=auth_headers,
3578 )
3579 assert resp.status_code == 200
3580 body = resp.json()
3581 assert body["dimension"] == "contour"
3582 assert body["ref"] == "main"
3583 data = body["data"]
3584 assert "shape" in data
3585 assert "pitchCurve" in data
3586 assert "overallDirection" in data
3587 assert "directionChanges" in data
3588 assert len(data["pitchCurve"]) > 0
3589 assert data["shape"] in ("arch", "ascending", "descending", "flat", "wave")
3590
3591
3592 @pytest.mark.anyio
3593 async def test_tempo_page_renders(
3594 client: AsyncClient,
3595 db_session: AsyncSession,
3596 ) -> None:
3597 """GET /musehub/ui/{repo_id}/analysis/{ref}/tempo returns 200 HTML."""
3598 repo_id = await _make_repo(db_session)
3599 ref = "abc1234567890abcdef"
3600 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/tempo")
3601 assert response.status_code == 200
3602 assert "text/html" in response.headers["content-type"]
3603
3604
3605 @pytest.mark.anyio
3606 async def test_tempo_page_no_auth_required(
3607 client: AsyncClient,
3608 db_session: AsyncSession,
3609 ) -> None:
3610 """Tempo analysis page must be accessible without a JWT (HTML shell handles auth)."""
3611 repo_id = await _make_repo(db_session)
3612 ref = "deadbeef5678"
3613 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/tempo")
3614 assert response.status_code != 401
3615 assert response.status_code == 200
3616
3617
3618 @pytest.mark.anyio
3619 async def test_tempo_page_contains_bpm_ui(
3620 client: AsyncClient,
3621 db_session: AsyncSession,
3622 ) -> None:
3623 """Tempo page must contain BPM display, stability bar, and tempo-change timeline."""
3624 repo_id = await _make_repo(db_session)
3625 ref = "feedface5678"
3626 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/tempo")
3627 assert response.status_code == 200
3628 body = response.text
3629 assert "Tempo Analysis" in body
3630 assert "BPM" in body
3631 assert "Stability" in body
3632 assert "tempoChangeSvg" in body or "tempoChanges" in body or "Tempo Changes" in body
3633 assert repo_id in body
3634
3635
3636 @pytest.mark.anyio
3637 async def test_tempo_json_response(
3638 client: AsyncClient,
3639 auth_headers: dict[str, str],
3640 db_session: AsyncSession,
3641 ) -> None:
3642 """GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/tempo returns TempoData.
3643
3644 Verifies that the JSON response includes BPM, stability, time feel, and
3645 tempo_changes history that the tempo page visualises.
3646 """
3647 resp = await client.post(
3648 "/api/v1/musehub/repos",
3649 json={"name": "tempo-test-repo", "owner": "testuser", "visibility": "private"},
3650 headers=auth_headers,
3651 )
3652 assert resp.status_code == 201
3653 repo_id = resp.json()["repoId"]
3654
3655 resp = await client.get(
3656 f"/api/v1/musehub/repos/{repo_id}/analysis/main/tempo",
3657 headers=auth_headers,
3658 )
3659 assert resp.status_code == 200
3660 body = resp.json()
3661 assert body["dimension"] == "tempo"
3662 assert body["ref"] == "main"
3663 data = body["data"]
3664 assert "bpm" in data
3665 assert "stability" in data
3666 assert "timeFeel" in data
3667 assert "tempoChanges" in data
3668 assert data["bpm"] > 0
3669 assert 0.0 <= data["stability"] <= 1.0
3670 assert isinstance(data["tempoChanges"], list)
3671
3672
3673 # ---------------------------------------------------------------------------
3674 # Form and structure page tests
3675 # ---------------------------------------------------------------------------
3676
3677
3678 @pytest.mark.anyio
3679 async def test_form_structure_page_renders(
3680 client: AsyncClient,
3681 db_session: AsyncSession,
3682 ) -> None:
3683 """GET /musehub/ui/{repo_id}/form-structure/{ref} returns 200 HTML without auth."""
3684 repo_id = await _make_repo(db_session)
3685 ref = "abc1234567890abcdef"
3686 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3687 assert response.status_code == 200
3688 assert "text/html" in response.headers["content-type"]
3689 body = response.text
3690 assert "Muse Hub" in body
3691 assert "Form" in body
3692
3693
3694 @pytest.mark.anyio
3695 async def test_form_structure_page_no_auth_required(
3696 client: AsyncClient,
3697 db_session: AsyncSession,
3698 ) -> None:
3699 """Form-structure UI page must be accessible without an Authorization header."""
3700 repo_id = await _make_repo(db_session)
3701 ref = "deadbeef1234"
3702 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3703 assert response.status_code != 401
3704 assert response.status_code == 200
3705
3706
3707 @pytest.mark.anyio
3708 async def test_form_structure_page_contains_section_map(
3709 client: AsyncClient,
3710 db_session: AsyncSession,
3711 ) -> None:
3712 """Form-structure page embeds section map SVG rendering logic."""
3713 repo_id = await _make_repo(db_session)
3714 ref = "cafebabe1234"
3715 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3716 assert response.status_code == 200
3717 body = response.text
3718 assert "Section Map" in body
3719 assert "renderSectionMap" in body
3720 assert "sectionMap" in body
3721
3722
3723 @pytest.mark.anyio
3724 async def test_form_structure_page_contains_repetition_panel(
3725 client: AsyncClient,
3726 db_session: AsyncSession,
3727 ) -> None:
3728 """Form-structure page embeds repetition structure panel."""
3729 repo_id = await _make_repo(db_session)
3730 ref = "feedface0123"
3731 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3732 assert response.status_code == 200
3733 body = response.text
3734 assert "Repetition" in body
3735 assert "renderRepetition" in body
3736
3737
3738 @pytest.mark.anyio
3739 async def test_form_structure_page_contains_heatmap(
3740 client: AsyncClient,
3741 db_session: AsyncSession,
3742 ) -> None:
3743 """Form-structure page embeds section comparison heatmap renderer."""
3744 repo_id = await _make_repo(db_session)
3745 ref = "deadcafe5678"
3746 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3747 assert response.status_code == 200
3748 body = response.text
3749 assert "Section Comparison" in body
3750 assert "renderHeatmap" in body
3751 assert "sectionComparison" in body
3752
3753
3754 @pytest.mark.anyio
3755 async def test_form_structure_page_includes_token_form(
3756 client: AsyncClient,
3757 db_session: AsyncSession,
3758 ) -> None:
3759 """Form-structure page includes the JWT token form and musehub.js auth infrastructure."""
3760 repo_id = await _make_repo(db_session)
3761 ref = "babe1234abcd"
3762 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3763 assert response.status_code == 200
3764 body = response.text
3765 assert "musehub.js" in body
3766 assert "token-form" in body
3767
3768
3769 @pytest.mark.anyio
3770 async def test_form_structure_json_response(
3771 client: AsyncClient,
3772 db_session: AsyncSession,
3773 auth_headers: dict[str, str],
3774 ) -> None:
3775 """GET /api/v1/musehub/repos/{repo_id}/form-structure/{ref} returns JSON with required fields."""
3776 repo_id = await _make_repo(db_session)
3777 ref = "abc1234567890abcdef"
3778 response = await client.get(
3779 f"/api/v1/musehub/repos/{repo_id}/form-structure/{ref}",
3780 headers=auth_headers,
3781 )
3782 assert response.status_code == 200
3783 body = response.json()
3784 assert "repoId" in body
3785 assert "ref" in body
3786 assert "formLabel" in body
3787 assert "timeSignature" in body
3788 assert "beatsPerBar" in body
3789 assert "totalBars" in body
3790 assert "sectionMap" in body
3791 assert "repetitionStructure" in body
3792 assert "sectionComparison" in body
3793 assert body["repoId"] == repo_id
3794 assert body["ref"] == ref
3795
3796
3797 @pytest.mark.anyio
3798 async def test_form_structure_json_section_map_fields(
3799 client: AsyncClient,
3800 db_session: AsyncSession,
3801 auth_headers: dict[str, str],
3802 ) -> None:
3803 """Each sectionMap entry has label, startBar, endBar, barCount, and colorHint."""
3804 repo_id = await _make_repo(db_session)
3805 ref = "abc1234567890abcdef"
3806 response = await client.get(
3807 f"/api/v1/musehub/repos/{repo_id}/form-structure/{ref}",
3808 headers=auth_headers,
3809 )
3810 assert response.status_code == 200
3811 body = response.json()
3812 sections = body["sectionMap"]
3813 assert len(sections) > 0
3814 for sec in sections:
3815 assert "label" in sec
3816 assert "function" in sec
3817 assert "startBar" in sec
3818 assert "endBar" in sec
3819 assert "barCount" in sec
3820 assert "colorHint" in sec
3821 assert sec["startBar"] >= 1
3822 assert sec["endBar"] >= sec["startBar"]
3823 assert sec["barCount"] >= 1
3824
3825
3826 @pytest.mark.anyio
3827 async def test_form_structure_json_heatmap_is_symmetric(
3828 client: AsyncClient,
3829 db_session: AsyncSession,
3830 auth_headers: dict[str, str],
3831 ) -> None:
3832 """Section comparison heatmap matrix must be square and symmetric with diagonal 1.0."""
3833 repo_id = await _make_repo(db_session)
3834 ref = "abc1234567890abcdef"
3835 response = await client.get(
3836 f"/api/v1/musehub/repos/{repo_id}/form-structure/{ref}",
3837 headers=auth_headers,
3838 )
3839 assert response.status_code == 200
3840 body = response.json()
3841 heatmap = body["sectionComparison"]
3842 labels = heatmap["labels"]
3843 matrix = heatmap["matrix"]
3844 n = len(labels)
3845 assert len(matrix) == n
3846 for i in range(n):
3847 assert len(matrix[i]) == n
3848 assert matrix[i][i] == 1.0
3849 for i in range(n):
3850 for j in range(n):
3851 assert 0.0 <= matrix[i][j] <= 1.0
3852
3853
3854 @pytest.mark.anyio
3855 async def test_form_structure_json_404_unknown_repo(
3856 client: AsyncClient,
3857 db_session: AsyncSession,
3858 auth_headers: dict[str, str],
3859 ) -> None:
3860 """GET /api/v1/musehub/repos/{unknown}/form-structure/{ref} returns 404."""
3861 response = await client.get(
3862 "/api/v1/musehub/repos/does-not-exist/form-structure/abc123",
3863 headers=auth_headers,
3864 )
3865 assert response.status_code == 404
3866
3867
3868 @pytest.mark.anyio
3869 async def test_form_structure_json_requires_auth(
3870 client: AsyncClient,
3871 db_session: AsyncSession,
3872 ) -> None:
3873 """GET /api/v1/musehub/repos/{repo_id}/form-structure/{ref} returns 401 without auth."""
3874 repo_id = await _make_repo(db_session)
3875 response = await client.get(
3876 f"/api/v1/musehub/repos/{repo_id}/form-structure/abc123",
3877 )
3878 assert response.status_code == 401
3879
3880
3881 # ---------------------------------------------------------------------------
3882 # Emotion map page tests (migrated to owner/slug routing)
3883 # ---------------------------------------------------------------------------
3884
3885 _EMOTION_REF = "deadbeef12345678"
3886
3887
3888 @pytest.mark.anyio
3889 async def test_emotion_page_renders(
3890 client: AsyncClient,
3891 db_session: AsyncSession,
3892 ) -> None:
3893 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/emotion returns 200 HTML without auth."""
3894 await _make_repo(db_session)
3895 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
3896 assert response.status_code == 200
3897 assert "text/html" in response.headers["content-type"]
3898 body = response.text
3899 assert "Muse Hub" in body
3900 assert "Emotion" in body
3901
3902
3903 @pytest.mark.anyio
3904 async def test_emotion_page_no_auth_required(
3905 client: AsyncClient,
3906 db_session: AsyncSession,
3907 ) -> None:
3908 """Emotion UI page must be accessible without an Authorization header (HTML shell)."""
3909 await _make_repo(db_session)
3910 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
3911 assert response.status_code != 401
3912 assert response.status_code == 200
3913
3914
3915 @pytest.mark.anyio
3916 async def test_emotion_page_includes_charts(
3917 client: AsyncClient,
3918 db_session: AsyncSession,
3919 ) -> None:
3920 """Emotion page SSR: must contain SVG scatter plot and axis dimension labels."""
3921 await _make_repo(db_session)
3922 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
3923 assert response.status_code == 200
3924 body = response.text
3925 assert "<circle" in body or "<svg" in body
3926 assert "Valence" in body
3927 assert "Tension" in body
3928 assert "Energy" in body
3929
3930
3931 @pytest.mark.anyio
3932 async def test_emotion_page_includes_filters(
3933 client: AsyncClient,
3934 db_session: AsyncSession,
3935 ) -> None:
3936 """Emotion page SSR: must contain summary vector bars and trajectory section."""
3937 await _make_repo(db_session)
3938 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
3939 assert response.status_code == 200
3940 body = response.text
3941 assert "SUMMARY VECTOR" in body
3942 assert "TRAJECTORY" in body
3943
3944
3945 @pytest.mark.anyio
3946 async def test_emotion_json_response(
3947 client: AsyncClient,
3948 db_session: AsyncSession,
3949 auth_headers: dict[str, str],
3950 ) -> None:
3951 """GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/emotion-map returns required fields."""
3952 repo_id = await _make_repo(db_session)
3953 response = await client.get(
3954 f"/api/v1/musehub/repos/{repo_id}/analysis/{_EMOTION_REF}/emotion-map",
3955 headers=auth_headers,
3956 )
3957 assert response.status_code == 200
3958 body = response.json()
3959 assert body["repoId"] == repo_id
3960 assert body["ref"] == _EMOTION_REF
3961 assert "computedAt" in body
3962 assert "summaryVector" in body
3963 sv = body["summaryVector"]
3964 for axis in ("energy", "valence", "tension", "darkness"):
3965 assert axis in sv
3966 assert 0.0 <= sv[axis] <= 1.0
3967 assert "evolution" in body
3968 assert isinstance(body["evolution"], list)
3969 assert len(body["evolution"]) > 0
3970 assert "narrative" in body
3971 assert len(body["narrative"]) > 0
3972 assert "source" in body
3973
3974
3975 @pytest.mark.anyio
3976 async def test_emotion_trajectory(
3977 client: AsyncClient,
3978 db_session: AsyncSession,
3979 auth_headers: dict[str, str],
3980 ) -> None:
3981 """Cross-commit trajectory must be a list of commit snapshots with emotion vectors."""
3982 repo_id = await _make_repo(db_session)
3983 response = await client.get(
3984 f"/api/v1/musehub/repos/{repo_id}/analysis/{_EMOTION_REF}/emotion-map",
3985 headers=auth_headers,
3986 )
3987 assert response.status_code == 200
3988 trajectory = response.json()["trajectory"]
3989 assert isinstance(trajectory, list)
3990 assert len(trajectory) >= 2
3991 for snapshot in trajectory:
3992 assert "commitId" in snapshot
3993 assert "message" in snapshot
3994 assert "primaryEmotion" in snapshot
3995 vector = snapshot["vector"]
3996 for axis in ("energy", "valence", "tension", "darkness"):
3997 assert axis in vector
3998 assert 0.0 <= vector[axis] <= 1.0
3999
4000
4001 @pytest.mark.anyio
4002 async def test_emotion_drift_distances(
4003 client: AsyncClient,
4004 db_session: AsyncSession,
4005 auth_headers: dict[str, str],
4006 ) -> None:
4007 """Drift list must have exactly len(trajectory) - 1 entries."""
4008 repo_id = await _make_repo(db_session)
4009 response = await client.get(
4010 f"/api/v1/musehub/repos/{repo_id}/analysis/{_EMOTION_REF}/emotion-map",
4011 headers=auth_headers,
4012 )
4013 assert response.status_code == 200
4014 body = response.json()
4015 trajectory = body["trajectory"]
4016 drift = body["drift"]
4017 assert isinstance(drift, list)
4018 assert len(drift) == len(trajectory) - 1
4019 for entry in drift:
4020 assert "fromCommit" in entry
4021 assert "toCommit" in entry
4022 assert "drift" in entry
4023 assert entry["drift"] >= 0.0
4024 assert "dominantChange" in entry
4025 assert entry["dominantChange"] in ("energy", "valence", "tension", "darkness")
4026
4027
4028 # ---------------------------------------------------------------------------
4029 # owner/slug navigation link correctness (regression for PR #282)
4030 # ---------------------------------------------------------------------------
4031
4032
4033 @pytest.mark.anyio
4034 async def test_ui_nav_links_use_owner_slug_not_uuid_repo_page(
4035 client: AsyncClient,
4036 db_session: AsyncSession,
4037 ) -> None:
4038 """Repo page must inject owner/slug base URL, not the internal UUID.
4039
4040 Before the fix, every handler except repo_page used ``const base =
4041 '/musehub/ui/' + repoId``. That produced UUID-based hrefs that 404 under
4042 the new /{owner}/{repo_slug} routing. This test guards the regression.
4043 """
4044 await _make_repo(db_session)
4045 response = await client.get("/musehub/ui/testuser/test-beats")
4046 assert response.status_code == 200
4047 body = response.text
4048 # JS base variable must use owner/slug, not UUID concatenation
4049 assert '"/musehub/ui/testuser/test-beats"' in body
4050 # UUID-concatenation pattern must NOT appear
4051 assert "'/musehub/ui/' + repoId" not in body
4052
4053
4054 @pytest.mark.anyio
4055 async def test_ui_nav_links_use_owner_slug_not_uuid_commit_page(
4056 client: AsyncClient,
4057 db_session: AsyncSession,
4058 ) -> None:
4059 """Commit page back-to-repo link must use owner/slug, not internal UUID."""
4060 from datetime import datetime, timezone
4061
4062 repo_id = await _make_repo(db_session)
4063 commit_id = "abc1234567890123456789012345678901234567"
4064 commit = MusehubCommit(
4065 commit_id=commit_id,
4066 repo_id=repo_id,
4067 branch="main",
4068 parent_ids=[],
4069 message="Test commit",
4070 author="testuser",
4071 timestamp=datetime.now(tz=timezone.utc),
4072 )
4073 db_session.add(commit)
4074 await db_session.commit()
4075
4076 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
4077 assert response.status_code == 200
4078 body = response.text
4079 assert "/musehub/ui/testuser/test-beats" in body
4080 assert "'/musehub/ui/' + repoId" not in body
4081
4082
4083 @pytest.mark.anyio
4084 async def test_ui_nav_links_use_owner_slug_not_uuid_graph_page(
4085 client: AsyncClient,
4086 db_session: AsyncSession,
4087 ) -> None:
4088 """Graph page back-to-repo link must use owner/slug, not internal UUID."""
4089 await _make_repo(db_session)
4090 response = await client.get("/musehub/ui/testuser/test-beats/graph")
4091 assert response.status_code == 200
4092 body = response.text
4093 assert '"/musehub/ui/testuser/test-beats"' in body
4094 assert "'/musehub/ui/' + repoId" not in body
4095
4096
4097 @pytest.mark.anyio
4098 async def test_ui_nav_links_use_owner_slug_not_uuid_pr_list_page(
4099 client: AsyncClient,
4100 db_session: AsyncSession,
4101 ) -> None:
4102 """PR list page navigation must use owner/slug, not internal UUID."""
4103 await _make_repo(db_session)
4104 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
4105 assert response.status_code == 200
4106 body = response.text
4107 assert '"/musehub/ui/testuser/test-beats"' in body
4108 assert "'/musehub/ui/' + repoId" not in body
4109
4110
4111 @pytest.mark.anyio
4112 async def test_ui_nav_links_use_owner_slug_not_uuid_releases_page(
4113 client: AsyncClient,
4114 db_session: AsyncSession,
4115 ) -> None:
4116 """Releases page navigation must use owner/slug, not internal UUID."""
4117 await _make_repo(db_session)
4118 response = await client.get("/musehub/ui/testuser/test-beats/releases")
4119 assert response.status_code == 200
4120 body = response.text
4121 assert '"/musehub/ui/testuser/test-beats"' in body
4122 assert "'/musehub/ui/' + repoId" not in body
4123
4124
4125 @pytest.mark.anyio
4126 async def test_ui_unknown_owner_slug_returns_404(
4127 client: AsyncClient,
4128 db_session: AsyncSession,
4129 ) -> None:
4130 """GET /musehub/ui/{unknown-owner}/{unknown-slug} must return 404."""
4131 response = await client.get("/musehub/ui/nobody/nonexistent-repo")
4132 assert response.status_code == 404
4133
4134
4135 # ---------------------------------------------------------------------------
4136 # Issue #199 — Design System Tests
4137 # ---------------------------------------------------------------------------
4138
4139
4140 @pytest.mark.anyio
4141 async def test_design_tokens_css_served(client: AsyncClient) -> None:
4142 """GET /musehub/static/tokens.css must return 200 with CSS content-type.
4143
4144 Verifies the design token file is reachable at its canonical static path.
4145 If this fails, every MuseHub page will render unstyled because the CSS
4146 custom properties (--bg-base, --color-accent, etc.) will be missing.
4147 """
4148 response = await client.get("/musehub/static/tokens.css")
4149 assert response.status_code == 200
4150 assert "text/css" in response.headers.get("content-type", "")
4151 body = response.text
4152 assert "--bg-base" in body
4153 assert "--color-accent" in body
4154 assert "--dim-harmonic" in body
4155
4156
4157 @pytest.mark.anyio
4158 async def test_components_css_served(client: AsyncClient) -> None:
4159 """GET /musehub/static/components.css must return 200 with CSS content.
4160
4161 Verifies the component class file is reachable. These classes (.card,
4162 .badge, .btn, etc.) are used on every MuseHub page.
4163 """
4164 response = await client.get("/musehub/static/components.css")
4165 assert response.status_code == 200
4166 assert "text/css" in response.headers.get("content-type", "")
4167 body = response.text
4168 assert ".badge" in body
4169 assert ".btn" in body
4170 assert ".card" in body
4171
4172
4173 @pytest.mark.anyio
4174 async def test_layout_css_served(client: AsyncClient) -> None:
4175 """GET /musehub/static/layout.css must return 200."""
4176 response = await client.get("/musehub/static/layout.css")
4177 assert response.status_code == 200
4178 assert "text/css" in response.headers.get("content-type", "")
4179 assert ".container" in response.text
4180
4181
4182 @pytest.mark.anyio
4183 async def test_icons_css_served(client: AsyncClient) -> None:
4184 """GET /musehub/static/icons.css must return 200."""
4185 response = await client.get("/musehub/static/icons.css")
4186 assert response.status_code == 200
4187 assert "text/css" in response.headers.get("content-type", "")
4188 assert ".icon-mid" in response.text
4189
4190
4191 @pytest.mark.anyio
4192 async def test_music_css_served(client: AsyncClient) -> None:
4193 """GET /musehub/static/music.css must return 200."""
4194 response = await client.get("/musehub/static/music.css")
4195 assert response.status_code == 200
4196 assert "text/css" in response.headers.get("content-type", "")
4197 assert ".piano-roll" in response.text
4198
4199
4200 @pytest.mark.anyio
4201 async def test_repo_page_uses_design_system(
4202 client: AsyncClient,
4203 db_session: AsyncSession,
4204 ) -> None:
4205 """Repo page HTML must reference all five design system CSS files.
4206
4207 This is the regression guard for the monolithic _CSS removal. If the
4208 _page() helper ever reverts to embedding CSS inline, this test will
4209 catch it by asserting the external link tags are present.
4210 """
4211 await _make_repo(db_session)
4212 response = await client.get("/musehub/ui/testuser/test-beats")
4213 assert response.status_code == 200
4214 body = response.text
4215 assert "/musehub/static/tokens.css" in body
4216 assert "/musehub/static/components.css" in body
4217 assert "/musehub/static/layout.css" in body
4218 assert "/musehub/static/icons.css" in body
4219 assert "/musehub/static/music.css" in body
4220
4221
4222 @pytest.mark.anyio
4223 async def test_responsive_meta_tag_present_repo_page(
4224 client: AsyncClient,
4225 db_session: AsyncSession,
4226 ) -> None:
4227 """Repo page must include a viewport meta tag for mobile responsiveness."""
4228 await _make_repo(db_session)
4229 response = await client.get("/musehub/ui/testuser/test-beats")
4230 assert response.status_code == 200
4231 assert 'name="viewport"' in response.text
4232
4233
4234 @pytest.mark.anyio
4235 async def test_responsive_meta_tag_present_pr_page(
4236 client: AsyncClient,
4237 db_session: AsyncSession,
4238 ) -> None:
4239 """PR list page must include a viewport meta tag for mobile responsiveness."""
4240 await _make_repo(db_session)
4241 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
4242 assert response.status_code == 200
4243 assert 'name="viewport"' in response.text
4244
4245
4246 @pytest.mark.anyio
4247 async def test_responsive_meta_tag_present_issues_page(
4248 client: AsyncClient,
4249 db_session: AsyncSession,
4250 ) -> None:
4251 """Issues page must include a viewport meta tag for mobile responsiveness."""
4252 await _make_repo(db_session)
4253 response = await client.get("/musehub/ui/testuser/test-beats/issues")
4254 assert response.status_code == 200
4255 assert 'name="viewport"' in response.text
4256
4257
4258 @pytest.mark.anyio
4259 async def test_design_tokens_css_contains_dimension_colors(
4260 client: AsyncClient,
4261 ) -> None:
4262 """tokens.css must define all five musical dimension color tokens.
4263
4264 These tokens are used in piano rolls, radar charts, and diff heatmaps.
4265 Missing tokens would break analysis page visualisations silently.
4266 """
4267 response = await client.get("/musehub/static/tokens.css")
4268 assert response.status_code == 200
4269 body = response.text
4270 for dim in ("harmonic", "rhythmic", "melodic", "structural", "dynamic"):
4271 assert f"--dim-{dim}:" in body, f"Missing dimension token --dim-{dim}"
4272
4273
4274 @pytest.mark.anyio
4275 async def test_design_tokens_css_contains_track_colors(
4276 client: AsyncClient,
4277 ) -> None:
4278 """tokens.css must define all 8 track color tokens (--track-0 through --track-7)."""
4279 response = await client.get("/musehub/static/tokens.css")
4280 assert response.status_code == 200
4281 body = response.text
4282 for i in range(8):
4283 assert f"--track-{i}:" in body, f"Missing track color token --track-{i}"
4284
4285
4286 @pytest.mark.anyio
4287 async def test_badge_variants_in_components_css(client: AsyncClient) -> None:
4288 """components.css must define all required badge variants including .badge-clean and .badge-dirty."""
4289 response = await client.get("/musehub/static/components.css")
4290 assert response.status_code == 200
4291 body = response.text
4292 for variant in ("open", "closed", "merged", "active", "clean", "dirty"):
4293 assert f".badge-{variant}" in body, f"Missing badge variant .badge-{variant}"
4294
4295
4296 @pytest.mark.anyio
4297 async def test_file_type_icons_in_icons_css(client: AsyncClient) -> None:
4298 """icons.css must define icon classes for all required file types."""
4299 response = await client.get("/musehub/static/icons.css")
4300 assert response.status_code == 200
4301 body = response.text
4302 for ext in ("mid", "mp3", "wav", "json", "webp", "xml", "abc"):
4303 assert f".icon-{ext}" in body, f"Missing file-type icon .icon-{ext}"
4304
4305
4306 @pytest.mark.anyio
4307 async def test_no_inline_css_on_repo_page(
4308 client: AsyncClient,
4309 db_session: AsyncSession,
4310 ) -> None:
4311 """Repo page must NOT embed the old monolithic CSS string inline.
4312
4313 Regression test: verifies the _CSS removal was not accidentally reverted.
4314 The old _CSS block contained the literal string 'background: #0d1117'
4315 inside a <style> tag in the <head>. After the design system migration,
4316 all styling comes from external files.
4317 """
4318 await _make_repo(db_session)
4319 response = await client.get("/musehub/ui/testuser/test-beats")
4320 body = response.text
4321 # Find the <head> section — inline CSS should not appear there
4322 head_end = body.find("</head>")
4323 head_section = body[:head_end] if head_end != -1 else body
4324 # The old monolithic block started with "box-sizing: border-box"
4325 # If it appears inside <head>, the migration has been reverted.
4326 assert "box-sizing: border-box; margin: 0; padding: 0;" not in head_section
4327
4328
4329 # ---------------------------------------------------------------------------
4330 # Analysis dashboard UI tests
4331 # ---------------------------------------------------------------------------
4332
4333
4334 @pytest.mark.anyio
4335 async def test_analysis_dashboard_renders(
4336 client: AsyncClient,
4337 db_session: AsyncSession,
4338 ) -> None:
4339 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref} returns 200 HTML without a JWT."""
4340 await _make_repo(db_session)
4341 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4342 assert response.status_code == 200
4343 assert "text/html" in response.headers["content-type"]
4344 body = response.text
4345 assert "Muse Hub" in body
4346 assert "Analysis" in body
4347 assert "test-beats" in body
4348
4349
4350 @pytest.mark.anyio
4351 async def test_analysis_dashboard_no_auth_required(
4352 client: AsyncClient,
4353 db_session: AsyncSession,
4354 ) -> None:
4355 """Analysis dashboard HTML shell must be accessible without an Authorization header."""
4356 await _make_repo(db_session)
4357 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4358 assert response.status_code == 200
4359 assert response.status_code != 401
4360
4361
4362 @pytest.mark.anyio
4363 async def test_analysis_dashboard_all_dimension_labels(
4364 client: AsyncClient,
4365 db_session: AsyncSession,
4366 ) -> None:
4367 """Dashboard HTML embeds all 10 required dimension card labels in the page script.
4368
4369 Regression test: if any card label is missing the JS template
4370 will silently skip rendering that dimension, so agents get an incomplete picture.
4371 """
4372 await _make_repo(db_session)
4373 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4374 assert response.status_code == 200
4375 body = response.text
4376 for label in ("Key", "Tempo", "Meter", "Chord Map", "Dynamics",
4377 "Groove", "Emotion", "Form", "Motifs", "Contour"):
4378 assert label in body, f"Expected dimension label {label!r} in dashboard HTML"
4379
4380
4381 @pytest.mark.anyio
4382 async def test_analysis_dashboard_sparkline_logic_present(
4383 client: AsyncClient,
4384 db_session: AsyncSession,
4385 ) -> None:
4386 """Dashboard renders dimension cards server-side with key musical data visible in HTML.
4387
4388 Updated for SSR migration (issue #578): the dashboard now renders all dimension
4389 data via Jinja2 rather than fetching via client-side JS. Key/tempo/meter/groove/form
4390 data is embedded directly in the HTML — no JS sparkline or API fetch is needed.
4391 """
4392 await _make_repo(db_session)
4393 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4394 assert response.status_code == 200
4395 body = response.text
4396 # SSR dashboard renders dimension cards with inline data (tonic, BPM, time-sig, etc.)
4397 assert "Key" in body
4398 assert "Tempo" in body
4399 assert "/analysis/" in body
4400
4401
4402 @pytest.mark.anyio
4403 async def test_analysis_dashboard_card_links_to_dimensions(
4404 client: AsyncClient,
4405 db_session: AsyncSession,
4406 ) -> None:
4407 """Each dimension card must link to the per-dimension analysis detail page.
4408
4409 The card href is built client-side from ``base + '/analysis/' + ref + '/' + id``,
4410 so the JS template string must reference ``/analysis/`` as the path segment.
4411 """
4412 await _make_repo(db_session)
4413 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4414 assert response.status_code == 200
4415 body = response.text
4416 assert "/analysis/" in body
4417
4418
4419
4420
4421 # ---------------------------------------------------------------------------
4422 # Motifs browser page — # ---------------------------------------------------------------------------
4423
4424
4425 @pytest.mark.anyio
4426 async def test_motifs_page_renders(
4427 client: AsyncClient,
4428 db_session: AsyncSession,
4429 ) -> None:
4430 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/motifs returns 200 HTML."""
4431 repo_id = await _make_repo(db_session)
4432 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4433 assert response.status_code == 200
4434 assert "text/html" in response.headers["content-type"]
4435 body = response.text
4436 assert "Muse Hub" in body
4437
4438
4439 @pytest.mark.anyio
4440 async def test_motifs_page_no_auth_required(
4441 client: AsyncClient,
4442 db_session: AsyncSession,
4443 ) -> None:
4444 """Motifs UI page must be accessible without an Authorization header."""
4445 repo_id = await _make_repo(db_session)
4446 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4447 assert response.status_code == 200
4448 assert response.status_code != 401
4449
4450
4451 @pytest.mark.anyio
4452 async def test_motifs_page_contains_filter_ui(
4453 client: AsyncClient,
4454 db_session: AsyncSession,
4455 ) -> None:
4456 """Motifs page SSR: must contain interval pattern section and occurrence markers."""
4457 repo_id = await _make_repo(db_session)
4458 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4459 assert response.status_code == 200
4460 body = response.text
4461 assert "INTERVAL PATTERN" in body
4462 assert "OCCURRENCES" in body
4463
4464
4465 @pytest.mark.anyio
4466 async def test_motifs_page_contains_piano_roll_renderer(
4467 client: AsyncClient,
4468 db_session: AsyncSession,
4469 ) -> None:
4470 """Motifs page SSR: must contain motif browser heading and interval data."""
4471 repo_id = await _make_repo(db_session)
4472 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4473 assert response.status_code == 200
4474 body = response.text
4475 assert "Motif Browser" in body
4476 assert "INTERVAL PATTERN" in body
4477
4478
4479 @pytest.mark.anyio
4480 async def test_motifs_page_contains_recurrence_grid(
4481 client: AsyncClient,
4482 db_session: AsyncSession,
4483 ) -> None:
4484 """Motifs page SSR: must contain recurrence grid section rendered server-side."""
4485 repo_id = await _make_repo(db_session)
4486 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4487 assert response.status_code == 200
4488 body = response.text
4489 assert "RECURRENCE GRID" in body or "occurrence" in body.lower()
4490
4491
4492 @pytest.mark.anyio
4493 async def test_motifs_page_shows_transformation_badges(
4494 client: AsyncClient,
4495 db_session: AsyncSession,
4496 ) -> None:
4497 """Motifs page SSR: must contain TRANSFORMATIONS section with inversion type labels."""
4498 repo_id = await _make_repo(db_session)
4499 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4500 assert response.status_code == 200
4501 body = response.text
4502 assert "TRANSFORMATIONS" in body
4503 assert "inversion" in body
4504
4505
4506 # ---------------------------------------------------------------------------
4507 # Content negotiation & repo home page tests — / #203
4508 # ---------------------------------------------------------------------------
4509
4510
4511 @pytest.mark.anyio
4512 async def test_repo_page_html_default(
4513 client: AsyncClient,
4514 db_session: AsyncSession,
4515 ) -> None:
4516 """GET /musehub/ui/{owner}/{repo_slug} with no Accept header returns HTML by default."""
4517 await _make_repo(db_session)
4518 response = await client.get("/musehub/ui/testuser/test-beats")
4519 assert response.status_code == 200
4520 assert "text/html" in response.headers["content-type"]
4521 body = response.text
4522 assert "Muse Hub" in body
4523 assert "testuser" in body
4524 assert "test-beats" in body
4525
4526
4527 @pytest.mark.anyio
4528 async def test_repo_home_shows_stats(
4529 client: AsyncClient,
4530 db_session: AsyncSession,
4531 ) -> None:
4532 """Repo home page embeds JS that fetches and renders the stats bar."""
4533 await _make_repo(db_session)
4534 response = await client.get("/musehub/ui/testuser/test-beats")
4535 assert response.status_code == 200
4536 body = response.text
4537 assert "stats-bar" in body
4538 assert "loadStats" in body
4539
4540
4541 @pytest.mark.anyio
4542 async def test_repo_home_recent_commits(
4543 client: AsyncClient,
4544 db_session: AsyncSession,
4545 ) -> None:
4546 """Repo home page embeds JS that renders the recent commits section."""
4547 await _make_repo(db_session)
4548 response = await client.get("/musehub/ui/testuser/test-beats")
4549 assert response.status_code == 200
4550 body = response.text
4551 assert "recent-commits" in body
4552 assert "loadRecentCommits" in body
4553
4554
4555 @pytest.mark.anyio
4556 async def test_repo_home_audio_player(
4557 client: AsyncClient,
4558 db_session: AsyncSession,
4559 ) -> None:
4560 """Repo home page embeds the audio player section and JS loader."""
4561 await _make_repo(db_session)
4562 response = await client.get("/musehub/ui/testuser/test-beats")
4563 assert response.status_code == 200
4564 body = response.text
4565 assert "audio-player-section" in body
4566 assert "loadAudioPlayer" in body
4567
4568
4569 @pytest.mark.anyio
4570 async def test_repo_page_json_accept(
4571 client: AsyncClient,
4572 db_session: AsyncSession,
4573 ) -> None:
4574 """GET /musehub/ui/{owner}/{repo_slug} with Accept: application/json returns JSON repo data."""
4575 await _make_repo(db_session)
4576 response = await client.get(
4577 "/musehub/ui/testuser/test-beats",
4578 headers={"Accept": "application/json"},
4579 )
4580 assert response.status_code == 200
4581 assert "application/json" in response.headers["content-type"]
4582 data = response.json()
4583 # RepoResponse fields serialised as camelCase
4584 assert "repoId" in data or "repo_id" in data or "slug" in data or "name" in data
4585
4586
4587 @pytest.mark.anyio
4588 async def test_commits_page_json_format_param(
4589 client: AsyncClient,
4590 db_session: AsyncSession,
4591 ) -> None:
4592 """GET /musehub/ui/{owner}/{repo_slug}/commits?format=json returns JSON commit list."""
4593 await _make_repo(db_session)
4594 response = await client.get("/musehub/ui/testuser/test-beats/commits?format=json")
4595 assert response.status_code == 200
4596 assert "application/json" in response.headers["content-type"]
4597 data = response.json()
4598 # CommitListResponse has commits (list) and total (int)
4599 assert "commits" in data
4600 assert "total" in data
4601 assert isinstance(data["commits"], list)
4602
4603
4604 @pytest.mark.anyio
4605 async def test_json_response_camelcase(
4606 client: AsyncClient,
4607 db_session: AsyncSession,
4608 ) -> None:
4609 """JSON response from repo page uses camelCase keys matching API convention."""
4610 await _make_repo(db_session)
4611 response = await client.get(
4612 "/musehub/ui/testuser/test-beats",
4613 headers={"Accept": "application/json"},
4614 )
4615 assert response.status_code == 200
4616 data = response.json()
4617 # All top-level keys must be camelCase — no underscores allowed in field names
4618 # (Pydantic by_alias=True serialises snake_case fields as camelCase)
4619 snake_keys = [k for k in data if "_" in k]
4620 assert snake_keys == [], f"Expected camelCase keys but found snake_case: {snake_keys}"
4621
4622
4623 @pytest.mark.anyio
4624 async def test_commits_list_html_default(
4625 client: AsyncClient,
4626 db_session: AsyncSession,
4627 ) -> None:
4628 """GET /musehub/ui/{owner}/{repo_slug}/commits with no Accept header returns HTML."""
4629 await _make_repo(db_session)
4630 response = await client.get("/musehub/ui/testuser/test-beats/commits")
4631 assert response.status_code == 200
4632 assert "text/html" in response.headers["content-type"]
4633
4634
4635 # ---------------------------------------------------------------------------
4636 # Tree browser tests — # ---------------------------------------------------------------------------
4637
4638
4639 async def _seed_tree_fixtures(db_session: AsyncSession) -> str:
4640 """Seed a public repo with a branch and objects for tree browser tests.
4641
4642 Creates:
4643 - repo: testuser/tree-test (public)
4644 - branch: main (head pointing at a dummy commit)
4645 - objects: tracks/bass.mid, tracks/keys.mp3, metadata.json, cover.webp
4646 Returns repo_id.
4647 """
4648 repo = MusehubRepo(
4649 name="tree-test",
4650 owner="testuser",
4651 slug="tree-test",
4652 visibility="public",
4653 owner_user_id="test-owner",
4654 )
4655 db_session.add(repo)
4656 await db_session.flush()
4657
4658 commit = MusehubCommit(
4659 commit_id="abc123def456",
4660 repo_id=str(repo.repo_id),
4661 message="initial",
4662 branch="main",
4663 author="testuser",
4664 timestamp=datetime.now(tz=UTC),
4665 )
4666 db_session.add(commit)
4667
4668 branch = MusehubBranch(
4669 repo_id=str(repo.repo_id),
4670 name="main",
4671 head_commit_id="abc123def456",
4672 )
4673 db_session.add(branch)
4674
4675 for path, size in [
4676 ("tracks/bass.mid", 2048),
4677 ("tracks/keys.mp3", 8192),
4678 ("metadata.json", 512),
4679 ("cover.webp", 4096),
4680 ]:
4681 obj = MusehubObject(
4682 object_id=f"sha256:{path.replace('/', '_')}",
4683 repo_id=str(repo.repo_id),
4684 path=path,
4685 size_bytes=size,
4686 disk_path=f"/tmp/{path.replace('/', '_')}",
4687 )
4688 db_session.add(obj)
4689
4690 await db_session.commit()
4691 return str(repo.repo_id)
4692
4693
4694 @pytest.mark.anyio
4695 async def test_tree_root_lists_directories(
4696 client: AsyncClient,
4697 db_session: AsyncSession,
4698 ) -> None:
4699 """GET /musehub/ui/{owner}/{repo}/tree/{ref} returns 200 HTML with tree JS."""
4700 await _seed_tree_fixtures(db_session)
4701 response = await client.get("/musehub/ui/testuser/tree-test/tree/main")
4702 assert response.status_code == 200
4703 assert "text/html" in response.headers["content-type"]
4704 body = response.text
4705 assert "tree" in body
4706 assert "branch-sel" in body or "ref-selector" in body or "loadTree" in body
4707
4708
4709 @pytest.mark.anyio
4710 async def test_tree_subdirectory_lists_files(
4711 client: AsyncClient,
4712 db_session: AsyncSession,
4713 ) -> None:
4714 """GET /{owner}/{repo}/tree/{ref}/tracks returns 200 HTML for the subdirectory."""
4715 await _seed_tree_fixtures(db_session)
4716 response = await client.get("/musehub/ui/testuser/tree-test/tree/main/tracks")
4717 assert response.status_code == 200
4718 assert "text/html" in response.headers["content-type"]
4719 body = response.text
4720 assert "tracks" in body
4721 assert "loadTree" in body
4722
4723
4724 @pytest.mark.anyio
4725 async def test_tree_file_icons_by_type(
4726 client: AsyncClient,
4727 db_session: AsyncSession,
4728 ) -> None:
4729 """Tree template includes JS that maps extensions to file-type icons."""
4730 await _seed_tree_fixtures(db_session)
4731 response = await client.get("/musehub/ui/testuser/tree-test/tree/main")
4732 assert response.status_code == 200
4733 body = response.text
4734 # Piano icon for .mid files
4735 assert ".mid" in body or "midi" in body
4736 # Waveform icon for .mp3/.wav files
4737 assert ".mp3" in body or ".wav" in body
4738 # Braces for .json
4739 assert ".json" in body
4740 # Photo for images
4741 assert ".webp" in body or ".png" in body
4742
4743
4744 @pytest.mark.anyio
4745 async def test_tree_breadcrumbs_correct(
4746 client: AsyncClient,
4747 db_session: AsyncSession,
4748 ) -> None:
4749 """Tree page breadcrumb contains owner, repo, tree, and ref."""
4750 await _seed_tree_fixtures(db_session)
4751 response = await client.get("/musehub/ui/testuser/tree-test/tree/main")
4752 assert response.status_code == 200
4753 body = response.text
4754 assert "testuser" in body
4755 assert "tree-test" in body
4756 assert "tree" in body
4757 assert "main" in body
4758
4759
4760 @pytest.mark.anyio
4761 async def test_tree_json_response(
4762 client: AsyncClient,
4763 db_session: AsyncSession,
4764 ) -> None:
4765 """GET /api/v1/musehub/repos/{repo_id}/tree/{ref} returns JSON with tree entries."""
4766 repo_id = await _seed_tree_fixtures(db_session)
4767 response = await client.get(
4768 f"/api/v1/musehub/repos/{repo_id}/tree/main"
4769 f"?owner=testuser&repo_slug=tree-test"
4770 )
4771 assert response.status_code == 200
4772 data = response.json()
4773 assert "entries" in data
4774 assert data["ref"] == "main"
4775 assert data["dirPath"] == ""
4776 # Root should show: 'tracks' dir, 'metadata.json', 'cover.webp'
4777 names = {e["name"] for e in data["entries"]}
4778 assert "tracks" in names
4779 assert "metadata.json" in names
4780 assert "cover.webp" in names
4781 # 'bass.mid' should NOT appear at root (it's under tracks/)
4782 assert "bass.mid" not in names
4783 # tracks entry must be a directory
4784 tracks_entry = next(e for e in data["entries"] if e["name"] == "tracks")
4785 assert tracks_entry["type"] == "dir"
4786 assert tracks_entry["sizeBytes"] is None
4787
4788
4789 @pytest.mark.anyio
4790 async def test_tree_unknown_ref_404(
4791 client: AsyncClient,
4792 db_session: AsyncSession,
4793 ) -> None:
4794 """GET /api/v1/musehub/repos/{repo_id}/tree/{unknown_ref} returns 404."""
4795 repo_id = await _seed_tree_fixtures(db_session)
4796 response = await client.get(
4797 f"/api/v1/musehub/repos/{repo_id}/tree/does-not-exist"
4798 f"?owner=testuser&repo_slug=tree-test"
4799 )
4800 assert response.status_code == 404
4801
4802
4803 # ---------------------------------------------------------------------------
4804 # Harmony analysis page tests — # ---------------------------------------------------------------------------
4805
4806
4807 @pytest.mark.anyio
4808 async def test_harmony_page_renders(
4809 client: AsyncClient,
4810 db_session: AsyncSession,
4811 ) -> None:
4812 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/harmony returns 200 SSR HTML."""
4813 await _make_repo(db_session)
4814 ref = "abc1234567890abcdef"
4815 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4816 assert response.status_code == 200
4817 assert "text/html" in response.headers["content-type"]
4818 body = response.text
4819 assert "Muse Hub" in body
4820 assert "Harmony Analysis" in body
4821
4822
4823 @pytest.mark.anyio
4824 async def test_harmony_page_no_auth_required(
4825 client: AsyncClient,
4826 db_session: AsyncSession,
4827 ) -> None:
4828 """Harmony analysis SSR page must be accessible without a JWT (not 401)."""
4829 await _make_repo(db_session)
4830 ref = "deadbeef00001234"
4831 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4832 assert response.status_code != 401
4833 assert response.status_code == 200
4834
4835
4836 @pytest.mark.anyio
4837 async def test_harmony_page_contains_key_display(
4838 client: AsyncClient,
4839 db_session: AsyncSession,
4840 ) -> None:
4841 """Harmony SSR page must render key and mode summary from HarmonyAnalysisResponse."""
4842 await _make_repo(db_session)
4843 ref = "cafe0000000000000001"
4844 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4845 assert response.status_code == 200
4846 body = response.text
4847 # SSR template renders key summary card with harmony_data.key (full key label e.g. "F major"),
4848 # harmony_data.mode (e.g. "major"), and harmonic_rhythm_bpm as "chords/min"
4849 assert "Harmony Analysis" in body
4850 assert "CHORD EVENTS" in body
4851 assert "chords/min" in body
4852
4853
4854 @pytest.mark.anyio
4855 async def test_harmony_page_contains_chord_timeline(
4856 client: AsyncClient,
4857 db_session: AsyncSession,
4858 ) -> None:
4859 """Harmony SSR page must render the Roman-numeral chord events section."""
4860 await _make_repo(db_session)
4861 ref = "babe0000000000000002"
4862 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4863 assert response.status_code == 200
4864 body = response.text
4865 # SSR template renders a CHORD EVENTS card with Roman numeral symbols
4866 assert "CHORD EVENTS" in body
4867
4868
4869 @pytest.mark.anyio
4870 async def test_harmony_page_contains_tension_curve(
4871 client: AsyncClient,
4872 db_session: AsyncSession,
4873 ) -> None:
4874 """Harmony SSR page must render the cadences section (replaces the old tension-curve card)."""
4875 await _make_repo(db_session)
4876 ref = "face0000000000000003"
4877 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4878 assert response.status_code == 200
4879 body = response.text
4880 # SSR template renders a CADENCES card (server-side, no JS SVG renderer needed)
4881 assert "CADENCES" in body
4882
4883
4884 @pytest.mark.anyio
4885 async def test_harmony_page_contains_modulation_section(
4886 client: AsyncClient,
4887 db_session: AsyncSession,
4888 ) -> None:
4889 """Harmony SSR page must render the MODULATIONS card server-side."""
4890 await _make_repo(db_session)
4891 ref = "feed0000000000000004"
4892 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4893 assert response.status_code == 200
4894 body = response.text
4895 # SSR template renders a MODULATIONS card from harmony_data.modulations
4896 assert "MODULATIONS" in body
4897
4898
4899 @pytest.mark.anyio
4900 async def test_harmony_page_contains_filter_controls(
4901 client: AsyncClient,
4902 db_session: AsyncSession,
4903 ) -> None:
4904 """Harmony SSR page must include HTMX fragment support (HX-Request returns partial HTML)."""
4905 await _make_repo(db_session)
4906 ref = "beef0000000000000005"
4907 # Full page response
4908 full = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4909 assert full.status_code == 200
4910 assert "<html" in full.text
4911 # HTMX fragment response (no outer HTML wrapper)
4912 fragment = await client.get(
4913 f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony",
4914 headers={"HX-Request": "true"},
4915 )
4916 assert fragment.status_code == 200
4917 assert "<html" not in fragment.text
4918 assert "Harmony Analysis" in fragment.text
4919
4920
4921 @pytest.mark.anyio
4922 async def test_harmony_page_contains_key_history(
4923 client: AsyncClient,
4924 db_session: AsyncSession,
4925 ) -> None:
4926 """Harmony SSR page must render breadcrumb with owner/repo_slug/analysis path."""
4927 await _make_repo(db_session)
4928 ref = "0000000000000000dead"
4929 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4930 assert response.status_code == 200
4931 body = response.text
4932 # SSR template breadcrumb shows owner, repo_slug, and analysis path
4933 assert "testuser" in body
4934 assert "test-beats" in body
4935 assert "analysis" in body
4936
4937
4938 @pytest.mark.anyio
4939 async def test_harmony_page_contains_voice_leading(
4940 client: AsyncClient,
4941 db_session: AsyncSession,
4942 ) -> None:
4943 """Harmony SSR page must render harmonic rhythm (replaces the old voice-leading JS card)."""
4944 await _make_repo(db_session)
4945 ref = "1111111111111111beef"
4946 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4947 assert response.status_code == 200
4948 body = response.text
4949 # SSR template renders harmonic_rhythm_bpm as "chords/min" in the key summary card
4950 assert "chords/min" in body
4951
4952
4953 @pytest.mark.anyio
4954 async def test_harmony_page_has_token_form(
4955 client: AsyncClient,
4956 db_session: AsyncSession,
4957 ) -> None:
4958 """Harmony SSR page must include JWT token form and musehub.js via base.html layout.
4959
4960 Auth state (localStorage / musehub_token) is managed by musehub.js; the
4961 base layout must include the token-form element and the musehub.js script tag.
4962 """
4963 await _make_repo(db_session)
4964 ref = "2222222222222222cafe"
4965 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4966 assert response.status_code == 200
4967 body = response.text
4968 assert 'id="token-form"' in body
4969 assert "musehub.js" in body
4970
4971
4972 @pytest.mark.anyio
4973 async def test_harmony_json_response(
4974 client: AsyncClient,
4975 db_session: AsyncSession,
4976 auth_headers: dict[str, str],
4977 ) -> None:
4978 """GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/harmony returns full harmonic JSON."""
4979 repo_id = await _make_repo(db_session)
4980 resp = await client.get(
4981 f"/api/v1/musehub/repos/{repo_id}/analysis/main/harmony",
4982 headers=auth_headers,
4983 )
4984 assert resp.status_code == 200
4985 body = resp.json()
4986 assert body["dimension"] == "harmony"
4987 assert body["ref"] == "main"
4988 data = body["data"]
4989 # Key and mode present
4990 assert "tonic" in data
4991 assert "mode" in data
4992 assert "keyConfidence" in data
4993 # Chord progression
4994 assert "chordProgression" in data
4995 assert isinstance(data["chordProgression"], list)
4996 if data["chordProgression"]:
4997 chord = data["chordProgression"][0]
4998 assert "beat" in chord
4999 assert "chord" in chord
5000 assert "function" in chord
5001 assert "tension" in chord
5002 # Tension curve
5003 assert "tensionCurve" in data
5004 assert isinstance(data["tensionCurve"], list)
5005 # Modulation points
5006 assert "modulationPoints" in data
5007 assert isinstance(data["modulationPoints"], list)
5008 # Total beats
5009 assert "totalBeats" in data
5010 assert data["totalBeats"] > 0
5011
5012 # Listen page tests
5013 # ---------------------------------------------------------------------------
5014
5015
5016 async def _seed_listen_fixtures(db_session: AsyncSession) -> str:
5017 """Seed a repo with audio objects for listen-page tests; return repo_id."""
5018 repo = MusehubRepo(
5019 name="listen-test",
5020 owner="testuser",
5021 slug="listen-test",
5022 visibility="public",
5023 owner_user_id="test-owner",
5024 )
5025 db_session.add(repo)
5026 await db_session.commit()
5027 await db_session.refresh(repo)
5028 repo_id = str(repo.repo_id)
5029
5030 for path, size in [
5031 ("mix/full_mix.mp3", 204800),
5032 ("tracks/bass.mp3", 51200),
5033 ("tracks/keys.mp3", 61440),
5034 ("tracks/bass.webp", 8192),
5035 ]:
5036 obj = MusehubObject(
5037 object_id=f"sha256:{path.replace('/', '_')}",
5038 repo_id=repo_id,
5039 path=path,
5040 size_bytes=size,
5041 disk_path=f"/tmp/{path.replace('/', '_')}",
5042 )
5043 db_session.add(obj)
5044 await db_session.commit()
5045 return repo_id
5046
5047
5048 @pytest.mark.anyio
5049 async def test_listen_page_full_mix(
5050 client: AsyncClient,
5051 db_session: AsyncSession,
5052 ) -> None:
5053 """GET /musehub/ui/{owner}/{repo}/listen/{ref} returns 200 HTML with player UI."""
5054 await _seed_listen_fixtures(db_session)
5055 ref = "main"
5056 response = await client.get(f"/musehub/ui/testuser/listen-test/listen/{ref}")
5057 assert response.status_code == 200
5058 assert "text/html" in response.headers["content-type"]
5059 body = response.text
5060 assert "Muse Hub" in body
5061 assert "listen" in body.lower()
5062 # Full-mix player elements present
5063 assert "mix-play-btn" in body
5064 assert "mix-progress-bar" in body
5065
5066
5067 @pytest.mark.anyio
5068 async def test_listen_page_track_listing(
5069 client: AsyncClient,
5070 db_session: AsyncSession,
5071 ) -> None:
5072 """Listen page HTML embeds track-listing JS that renders per-track controls."""
5073 await _seed_listen_fixtures(db_session)
5074 ref = "main"
5075 response = await client.get(f"/musehub/ui/testuser/listen-test/listen/{ref}")
5076 assert response.status_code == 200
5077 body = response.text
5078 # Track-listing JavaScript is embedded
5079 assert "track-list" in body
5080 assert "track-play-btn" in body or "playTrack" in body
5081
5082
5083 @pytest.mark.anyio
5084 async def test_listen_page_no_renders_fallback(
5085 client: AsyncClient,
5086 db_session: AsyncSession,
5087 ) -> None:
5088 """Listen page renders a friendly fallback when no audio artifacts exist."""
5089 # Repo with no objects at all
5090 repo = MusehubRepo(
5091 name="silent-repo",
5092 owner="testuser",
5093 slug="silent-repo",
5094 visibility="public",
5095 owner_user_id="test-owner",
5096 )
5097 db_session.add(repo)
5098 await db_session.commit()
5099
5100 response = await client.get("/musehub/ui/testuser/silent-repo/listen/main")
5101 assert response.status_code == 200
5102 body = response.text
5103 # Fallback UI marker present (no-renders state)
5104 assert "no-renders" in body or "No audio" in body or "hasRenders" in body
5105
5106
5107 @pytest.mark.anyio
5108 async def test_listen_page_json_response(
5109 client: AsyncClient,
5110 db_session: AsyncSession,
5111 ) -> None:
5112 """GET /musehub/ui/{owner}/{repo}/listen/{ref}?format=json returns TrackListingResponse."""
5113 await _seed_listen_fixtures(db_session)
5114 ref = "main"
5115 response = await client.get(
5116 f"/musehub/ui/testuser/listen-test/listen/{ref}",
5117 params={"format": "json"},
5118 )
5119 assert response.status_code == 200
5120 assert "application/json" in response.headers["content-type"]
5121 body = response.json()
5122 assert "repoId" in body
5123 assert "ref" in body
5124 assert body["ref"] == ref
5125 assert "tracks" in body
5126 assert "hasRenders" in body
5127 assert isinstance(body["tracks"], list)
5128
5129
5130 # ---------------------------------------------------------------------------
5131 # Issue #366 — musehub_listen service function (direct unit tests)
5132 # ---------------------------------------------------------------------------
5133
5134
5135 @pytest.mark.anyio
5136 async def test_build_track_listing_returns_full_mix_and_tracks(
5137 db_session: AsyncSession,
5138 ) -> None:
5139 """build_track_listing() returns a populated TrackListingResponse with mix + stems."""
5140 from musehub.services.musehub_listen import build_track_listing
5141
5142 repo = MusehubRepo(
5143 name="svc-listen-test",
5144 owner="svcuser",
5145 slug="svc-listen-test",
5146 visibility="public",
5147 owner_user_id="svc-owner",
5148 )
5149 db_session.add(repo)
5150 await db_session.commit()
5151 await db_session.refresh(repo)
5152 repo_id = str(repo.repo_id)
5153
5154 for path, size in [
5155 ("mix/full_mix.mp3", 204800),
5156 ("tracks/bass.mp3", 51200),
5157 ("tracks/keys.mp3", 61440),
5158 ("tracks/bass.webp", 8192),
5159 ]:
5160 obj = MusehubObject(
5161 object_id=f"sha256:svc_{path.replace('/', '_')}",
5162 repo_id=repo_id,
5163 path=path,
5164 size_bytes=size,
5165 disk_path=f"/tmp/svc_{path.replace('/', '_')}",
5166 )
5167 db_session.add(obj)
5168 await db_session.commit()
5169
5170 result = await build_track_listing(db_session, repo_id, "main")
5171
5172 assert result.has_renders is True
5173 assert result.repo_id == repo_id
5174 assert result.ref == "main"
5175 # full-mix URL points to the mix file (contains "mix" keyword)
5176 assert result.full_mix_url is not None
5177 assert "full_mix" in result.full_mix_url or "mix" in result.full_mix_url
5178 # Two audio tracks (bass.mp3 + keys.mp3); bass.webp is not audio
5179 assert len(result.tracks) == 3 # mix/full_mix.mp3, tracks/bass.mp3, tracks/keys.mp3
5180 track_paths = {t.path for t in result.tracks}
5181 assert "tracks/bass.mp3" in track_paths
5182 assert "tracks/keys.mp3" in track_paths
5183 # Piano-roll URL attached to bass.mp3 (matching bass.webp exists)
5184 bass_track = next(t for t in result.tracks if t.path == "tracks/bass.mp3")
5185 assert bass_track.piano_roll_url is not None
5186
5187
5188 @pytest.mark.anyio
5189 async def test_build_track_listing_no_audio_returns_empty(
5190 db_session: AsyncSession,
5191 ) -> None:
5192 """build_track_listing() returns has_renders=False when no audio objects exist."""
5193 from musehub.services.musehub_listen import build_track_listing
5194
5195 repo = MusehubRepo(
5196 name="svc-silent-test",
5197 owner="svcuser",
5198 slug="svc-silent-test",
5199 visibility="public",
5200 owner_user_id="svc-owner",
5201 )
5202 db_session.add(repo)
5203 await db_session.commit()
5204 await db_session.refresh(repo)
5205 repo_id = str(repo.repo_id)
5206
5207 # Only a non-audio object
5208 obj = MusehubObject(
5209 object_id="sha256:svc_midi",
5210 repo_id=repo_id,
5211 path="tracks/bass.mid",
5212 size_bytes=1024,
5213 disk_path="/tmp/svc_bass.mid",
5214 )
5215 db_session.add(obj)
5216 await db_session.commit()
5217
5218 result = await build_track_listing(db_session, repo_id, "dev")
5219
5220 assert result.has_renders is False
5221 assert result.full_mix_url is None
5222 assert result.tracks == []
5223
5224
5225 @pytest.mark.anyio
5226 async def test_build_track_listing_no_mix_keyword_uses_first_alphabetically(
5227 db_session: AsyncSession,
5228 ) -> None:
5229 """When no file matches _FULL_MIX_KEYWORDS, the first audio file (by path) is used."""
5230 from musehub.services.musehub_listen import build_track_listing
5231
5232 repo = MusehubRepo(
5233 name="svc-nomix-test",
5234 owner="svcuser",
5235 slug="svc-nomix-test",
5236 visibility="public",
5237 owner_user_id="svc-owner",
5238 )
5239 db_session.add(repo)
5240 await db_session.commit()
5241 await db_session.refresh(repo)
5242 repo_id = str(repo.repo_id)
5243
5244 for path, size in [
5245 ("tracks/bass.mp3", 51200),
5246 ("tracks/drums.mp3", 61440),
5247 ]:
5248 obj = MusehubObject(
5249 object_id=f"sha256:svc_nomix_{path.replace('/', '_')}",
5250 repo_id=repo_id,
5251 path=path,
5252 size_bytes=size,
5253 disk_path=f"/tmp/svc_nomix_{path.replace('/', '_')}",
5254 )
5255 db_session.add(obj)
5256 await db_session.commit()
5257
5258 result = await build_track_listing(db_session, repo_id, "main")
5259
5260 assert result.has_renders is True
5261 # 'tracks/bass.mp3' sorts before 'tracks/drums.mp3'
5262 assert result.full_mix_url is not None
5263 assert "bass" in result.full_mix_url
5264
5265
5266 # ---------------------------------------------------------------------------
5267 # Issue #206 — Commit list page
5268 # ---------------------------------------------------------------------------
5269
5270 _COMMIT_LIST_OWNER = "commitowner"
5271 _COMMIT_LIST_SLUG = "commit-list-repo"
5272 _SHA_MAIN_1 = "aa001122334455667788990011223344556677889900"
5273 _SHA_MAIN_2 = "bb001122334455667788990011223344556677889900"
5274 _SHA_MAIN_MERGE = "cc001122334455667788990011223344556677889900"
5275 _SHA_FEAT = "ff001122334455667788990011223344556677889900"
5276
5277
5278 async def _seed_commit_list_repo(
5279 db_session: AsyncSession,
5280 ) -> str:
5281 """Seed a repo with 2 commits on main, 1 merge commit, and 1 on feat branch."""
5282 repo = MusehubRepo(
5283 name=_COMMIT_LIST_SLUG,
5284 owner=_COMMIT_LIST_OWNER,
5285 slug=_COMMIT_LIST_SLUG,
5286 visibility="public",
5287 owner_user_id="commit-owner-uid",
5288 )
5289 db_session.add(repo)
5290 await db_session.flush()
5291 repo_id = str(repo.repo_id)
5292
5293 branch_main = MusehubBranch(repo_id=repo_id, name="main", head_commit_id=_SHA_MAIN_MERGE)
5294 branch_feat = MusehubBranch(repo_id=repo_id, name="feat/drums", head_commit_id=_SHA_FEAT)
5295 db_session.add_all([branch_main, branch_feat])
5296
5297 now = datetime.now(UTC)
5298 commits = [
5299 MusehubCommit(
5300 commit_id=_SHA_MAIN_1,
5301 repo_id=repo_id,
5302 branch="main",
5303 parent_ids=[],
5304 message="feat(bass): root commit with walking bass line",
5305 author="composer@muse.app",
5306 timestamp=now - timedelta(hours=4),
5307 ),
5308 MusehubCommit(
5309 commit_id=_SHA_MAIN_2,
5310 repo_id=repo_id,
5311 branch="main",
5312 parent_ids=[_SHA_MAIN_1],
5313 message="feat(keys): add rhodes chord voicings in verse",
5314 author="composer@muse.app",
5315 timestamp=now - timedelta(hours=2),
5316 ),
5317 MusehubCommit(
5318 commit_id=_SHA_MAIN_MERGE,
5319 repo_id=repo_id,
5320 branch="main",
5321 parent_ids=[_SHA_MAIN_2, _SHA_FEAT],
5322 message="merge(feat/drums): integrate drum pattern into main",
5323 author="composer@muse.app",
5324 timestamp=now - timedelta(hours=1),
5325 ),
5326 MusehubCommit(
5327 commit_id=_SHA_FEAT,
5328 repo_id=repo_id,
5329 branch="feat/drums",
5330 parent_ids=[_SHA_MAIN_1],
5331 message="feat(drums): add kick and snare pattern at 120 BPM",
5332 author="drummer@muse.app",
5333 timestamp=now - timedelta(hours=3),
5334 ),
5335 ]
5336 db_session.add_all(commits)
5337 await db_session.commit()
5338 return repo_id
5339
5340
5341 @pytest.mark.anyio
5342 async def test_commits_list_page_returns_200(
5343 client: AsyncClient,
5344 db_session: AsyncSession,
5345 ) -> None:
5346 """GET /{owner}/{repo}/commits returns 200 HTML."""
5347 await _seed_commit_list_repo(db_session)
5348 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5349 assert resp.status_code == 200
5350 assert "text/html" in resp.headers["content-type"]
5351 assert "Muse Hub" in resp.text
5352
5353
5354 @pytest.mark.anyio
5355 async def test_commits_list_page_shows_commit_sha(
5356 client: AsyncClient,
5357 db_session: AsyncSession,
5358 ) -> None:
5359 """Commit SHA (first 8 chars) appears in the rendered HTML."""
5360 await _seed_commit_list_repo(db_session)
5361 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5362 assert resp.status_code == 200
5363 # All 4 commits should appear (per_page=30 default, total=4)
5364 assert _SHA_MAIN_1[:8] in resp.text
5365 assert _SHA_MAIN_2[:8] in resp.text
5366 assert _SHA_MAIN_MERGE[:8] in resp.text
5367 assert _SHA_FEAT[:8] in resp.text
5368
5369
5370 @pytest.mark.anyio
5371 async def test_commits_list_page_shows_commit_message(
5372 client: AsyncClient,
5373 db_session: AsyncSession,
5374 ) -> None:
5375 """Commit messages appear truncated in commit rows."""
5376 await _seed_commit_list_repo(db_session)
5377 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5378 assert resp.status_code == 200
5379 assert "walking bass line" in resp.text
5380 assert "rhodes chord voicings" in resp.text
5381
5382
5383 @pytest.mark.anyio
5384 async def test_commits_list_page_dag_indicator(
5385 client: AsyncClient,
5386 db_session: AsyncSession,
5387 ) -> None:
5388 """DAG node CSS class is present in the HTML for every commit row."""
5389 await _seed_commit_list_repo(db_session)
5390 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5391 assert resp.status_code == 200
5392 assert "dag-node" in resp.text
5393 assert "commit-list-row" in resp.text
5394
5395
5396 @pytest.mark.anyio
5397 async def test_commits_list_page_merge_indicator(
5398 client: AsyncClient,
5399 db_session: AsyncSession,
5400 ) -> None:
5401 """Merge commits display the merge indicator and dag-node-merge class."""
5402 await _seed_commit_list_repo(db_session)
5403 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5404 assert resp.status_code == 200
5405 assert "dag-node-merge" in resp.text
5406 assert "merge" in resp.text.lower()
5407
5408
5409 @pytest.mark.anyio
5410 async def test_commits_list_page_branch_selector(
5411 client: AsyncClient,
5412 db_session: AsyncSession,
5413 ) -> None:
5414 """Branch <select> dropdown is present when the repo has branches."""
5415 await _seed_commit_list_repo(db_session)
5416 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5417 assert resp.status_code == 200
5418 # Select element with branch options
5419 assert "branch-sel" in resp.text
5420 assert "main" in resp.text
5421 assert "feat/drums" in resp.text
5422
5423
5424 @pytest.mark.anyio
5425 async def test_commits_list_page_graph_link(
5426 client: AsyncClient,
5427 db_session: AsyncSession,
5428 ) -> None:
5429 """Link to the DAG graph page is present."""
5430 await _seed_commit_list_repo(db_session)
5431 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5432 assert resp.status_code == 200
5433 assert "/graph" in resp.text
5434
5435
5436 @pytest.mark.anyio
5437 async def test_commits_list_page_pagination_links(
5438 client: AsyncClient,
5439 db_session: AsyncSession,
5440 ) -> None:
5441 """Pagination nav links appear when total exceeds per_page."""
5442 await _seed_commit_list_repo(db_session)
5443 # Request per_page=2 so 4 commits produce 2 pages
5444 resp = await client.get(
5445 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=1"
5446 )
5447 assert resp.status_code == 200
5448 body = resp.text
5449 # "Older" link should be active (page 1 has no "Newer")
5450 assert "Older" in body
5451 # "Newer" should be disabled on page 1
5452 assert "Newer" in body
5453 assert "page=2" in body
5454
5455
5456 @pytest.mark.anyio
5457 async def test_commits_list_page_pagination_page2(
5458 client: AsyncClient,
5459 db_session: AsyncSession,
5460 ) -> None:
5461 """Page 2 renders with Newer navigation active."""
5462 await _seed_commit_list_repo(db_session)
5463 resp = await client.get(
5464 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=2"
5465 )
5466 assert resp.status_code == 200
5467 body = resp.text
5468 assert "page=1" in body # "Newer" link points back to page 1
5469
5470
5471 @pytest.mark.anyio
5472 async def test_commits_list_page_branch_filter_html(
5473 client: AsyncClient,
5474 db_session: AsyncSession,
5475 ) -> None:
5476 """?branch=main returns only main-branch commits in HTML."""
5477 await _seed_commit_list_repo(db_session)
5478 resp = await client.get(
5479 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?branch=main"
5480 )
5481 assert resp.status_code == 200
5482 body = resp.text
5483 # main commits appear
5484 assert _SHA_MAIN_1[:8] in body
5485 assert _SHA_MAIN_2[:8] in body
5486 assert _SHA_MAIN_MERGE[:8] in body
5487 # feat/drums commit should NOT appear when filtered to main
5488 assert _SHA_FEAT[:8] not in body
5489
5490
5491 @pytest.mark.anyio
5492 async def test_commits_list_page_json_content_negotiation(
5493 client: AsyncClient,
5494 db_session: AsyncSession,
5495 ) -> None:
5496 """?format=json returns CommitListResponse JSON with commits and total."""
5497 await _seed_commit_list_repo(db_session)
5498 resp = await client.get(
5499 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?format=json"
5500 )
5501 assert resp.status_code == 200
5502 assert "application/json" in resp.headers["content-type"]
5503 body = resp.json()
5504 assert "commits" in body
5505 assert "total" in body
5506 assert body["total"] == 4
5507 assert len(body["commits"]) == 4
5508 # Commits are newest first; merge commit has timestamp now-1h (most recent)
5509 commit_ids = [c["commitId"] for c in body["commits"]]
5510 assert commit_ids[0] == _SHA_MAIN_MERGE
5511
5512
5513 @pytest.mark.anyio
5514 async def test_commits_list_page_json_pagination(
5515 client: AsyncClient,
5516 db_session: AsyncSession,
5517 ) -> None:
5518 """JSON with per_page=1&page=2 returns the second commit."""
5519 await _seed_commit_list_repo(db_session)
5520 resp = await client.get(
5521 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits"
5522 "?format=json&per_page=1&page=2"
5523 )
5524 assert resp.status_code == 200
5525 body = resp.json()
5526 assert body["total"] == 4
5527 assert len(body["commits"]) == 1
5528 # Page 2 (newest-first) is the second most-recent commit.
5529 # Newest: _SHA_MAIN_MERGE (now-1h), then _SHA_MAIN_2 (now-2h)
5530 assert body["commits"][0]["commitId"] == _SHA_MAIN_2
5531
5532
5533 @pytest.mark.anyio
5534 async def test_commits_list_page_empty_state(
5535 client: AsyncClient,
5536 db_session: AsyncSession,
5537 ) -> None:
5538 """A repo with no commits shows the empty state message."""
5539 repo = MusehubRepo(
5540 name="empty-repo",
5541 owner="emptyowner",
5542 slug="empty-repo",
5543 visibility="public",
5544 owner_user_id="empty-owner-uid",
5545 )
5546 db_session.add(repo)
5547 await db_session.commit()
5548
5549 resp = await client.get("/musehub/ui/emptyowner/empty-repo/commits")
5550 assert resp.status_code == 200
5551 assert "No commits yet" in resp.text or "muse push" in resp.text
5552
5553
5554 # ---------------------------------------------------------------------------
5555
5556
5557
5558 # ---------------------------------------------------------------------------
5559 # Commit detail enhancements — # ---------------------------------------------------------------------------
5560
5561
5562 async def _seed_commit_detail_fixtures(
5563 db_session: AsyncSession,
5564 ) -> tuple[str, str, str]:
5565 """Seed a public repo with a parent commit and a child commit.
5566
5567 Returns (repo_id, parent_commit_id, child_commit_id).
5568 """
5569 repo = MusehubRepo(
5570 name="commit-detail-test",
5571 owner="testuser",
5572 slug="commit-detail-test",
5573 visibility="public",
5574 owner_user_id="test-owner",
5575 )
5576 db_session.add(repo)
5577 await db_session.flush()
5578 repo_id = str(repo.repo_id)
5579
5580 branch = MusehubBranch(
5581 repo_id=repo_id,
5582 name="main",
5583 head_commit_id=None,
5584 )
5585 db_session.add(branch)
5586
5587 parent_commit_id = "aaaa0000111122223333444455556666aaaabbbb"
5588 child_commit_id = "bbbb1111222233334444555566667777bbbbcccc"
5589
5590 parent_commit = MusehubCommit(
5591 repo_id=repo_id,
5592 commit_id=parent_commit_id,
5593 branch="main",
5594 parent_ids=[],
5595 message="init: establish harmonic foundation in C major\n\nKey: C major\nBPM: 120\nMeter: 4/4",
5596 author="testuser",
5597 timestamp=datetime.now(UTC) - timedelta(hours=2),
5598 snapshot_id=None,
5599 )
5600 child_commit = MusehubCommit(
5601 repo_id=repo_id,
5602 commit_id=child_commit_id,
5603 branch="main",
5604 parent_ids=[parent_commit_id],
5605 message="feat(keys): add melodic piano phrase in D minor\n\nKey: D minor\nBPM: 132\nMeter: 3/4\nSection: verse",
5606 author="testuser",
5607 timestamp=datetime.now(UTC) - timedelta(hours=1),
5608 snapshot_id=None,
5609 )
5610 db_session.add(parent_commit)
5611 db_session.add(child_commit)
5612 await db_session.commit()
5613 return repo_id, parent_commit_id, child_commit_id
5614
5615
5616 @pytest.mark.anyio
5617 async def test_commit_detail_page_renders_enhanced_metadata(
5618 client: AsyncClient,
5619 db_session: AsyncSession,
5620 ) -> None:
5621 """Commit detail page SSR renders commit header fields (SHA, author, branch, parent link)."""
5622 await _seed_commit_detail_fixtures(db_session)
5623 sha = "bbbb1111222233334444555566667777bbbbcccc"
5624 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
5625 assert response.status_code == 200
5626 assert "text/html" in response.headers["content-type"]
5627 body = response.text
5628 # SSR commit header — short SHA present
5629 assert "bbbb1111" in body
5630 # Author field rendered server-side
5631 assert "testuser" in body
5632 # Parent SHA navigation link present
5633 assert "aaaa0000" in body
5634
5635
5636 @pytest.mark.anyio
5637 async def test_commit_detail_audio_shell_with_snapshot_id(
5638 client: AsyncClient,
5639 db_session: AsyncSession,
5640 ) -> None:
5641 """Commit with snapshot_id gets a WaveSurfer shell rendered by the server."""
5642 from datetime import datetime, timezone
5643
5644 _repo_id, _parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5645 repo = MusehubRepo(
5646 name="audio-test-repo",
5647 owner="testuser",
5648 slug="audio-test-repo",
5649 visibility="public",
5650 owner_user_id="test-owner",
5651 )
5652 db_session.add(repo)
5653 await db_session.flush()
5654 snap_id = "sha256:deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678"
5655 commit_with_audio = MusehubCommit(
5656 commit_id="cccc2222333344445555666677778888ccccdddd",
5657 repo_id=str(repo.repo_id),
5658 branch="main",
5659 parent_ids=[],
5660 message="Commit with audio snapshot",
5661 author="testuser",
5662 timestamp=datetime.now(tz=timezone.utc),
5663 snapshot_id=snap_id,
5664 )
5665 db_session.add(commit_with_audio)
5666 await db_session.commit()
5667
5668 response = await client.get(
5669 f"/musehub/ui/testuser/audio-test-repo/commits/cccc2222333344445555666677778888ccccdddd"
5670 )
5671 assert response.status_code == 200
5672 body = response.text
5673 assert "commit-waveform" in body
5674 assert snap_id in body
5675
5676
5677 @pytest.mark.anyio
5678 async def test_commit_detail_ssr_message_present_in_body(
5679 client: AsyncClient,
5680 db_session: AsyncSession,
5681 ) -> None:
5682 """Commit message text is rendered in the SSR page body (replaces JS renderCommitBody)."""
5683 await _seed_commit_detail_fixtures(db_session)
5684 sha = "bbbb1111222233334444555566667777bbbbcccc"
5685 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
5686 assert response.status_code == 200
5687 body = response.text
5688 # SSR renders the commit message directly — no JS renderCommitBody needed
5689 assert "feat(keys): add melodic piano phrase in D minor" in body
5690
5691
5692 @pytest.mark.anyio
5693 async def test_commit_detail_diff_summary_endpoint_returns_five_dimensions(
5694 client: AsyncClient,
5695 db_session: AsyncSession,
5696 auth_headers: dict[str, str],
5697 ) -> None:
5698 """GET /api/v1/musehub/repos/{repo_id}/commits/{sha}/diff-summary returns 5 dimensions."""
5699 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5700 response = await client.get(
5701 f"/api/v1/musehub/repos/{repo_id}/commits/{child_id}/diff-summary",
5702 headers=auth_headers,
5703 )
5704 assert response.status_code == 200
5705 data = response.json()
5706 assert data["commitId"] == child_id
5707 assert data["parentId"] == _parent_id
5708 assert "dimensions" in data
5709 assert len(data["dimensions"]) == 5
5710 dim_names = {d["dimension"] for d in data["dimensions"]}
5711 assert dim_names == {"harmonic", "rhythmic", "melodic", "structural", "dynamic"}
5712 for dim in data["dimensions"]:
5713 assert 0.0 <= dim["score"] <= 1.0
5714 assert dim["label"] in {"none", "low", "medium", "high"}
5715 assert dim["color"] in {"dim-none", "dim-low", "dim-medium", "dim-high"}
5716 assert "overallScore" in data
5717 assert 0.0 <= data["overallScore"] <= 1.0
5718
5719
5720 @pytest.mark.anyio
5721 async def test_commit_detail_diff_summary_root_commit_scores_one(
5722 client: AsyncClient,
5723 db_session: AsyncSession,
5724 auth_headers: dict[str, str],
5725 ) -> None:
5726 """Diff summary for a root commit (no parent) scores all dimensions at 1.0."""
5727 repo_id, parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5728 response = await client.get(
5729 f"/api/v1/musehub/repos/{repo_id}/commits/{parent_id}/diff-summary",
5730 headers=auth_headers,
5731 )
5732 assert response.status_code == 200
5733 data = response.json()
5734 assert data["parentId"] is None
5735 for dim in data["dimensions"]:
5736 assert dim["score"] == 1.0
5737 assert dim["label"] == "high"
5738
5739
5740 @pytest.mark.anyio
5741 async def test_commit_detail_diff_summary_keyword_detection(
5742 client: AsyncClient,
5743 db_session: AsyncSession,
5744 auth_headers: dict[str, str],
5745 ) -> None:
5746 """Diff summary detects melodic keyword in child commit message."""
5747 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5748 response = await client.get(
5749 f"/api/v1/musehub/repos/{repo_id}/commits/{child_id}/diff-summary",
5750 headers=auth_headers,
5751 )
5752 assert response.status_code == 200
5753 data = response.json()
5754 melodic_dim = next(d for d in data["dimensions"] if d["dimension"] == "melodic")
5755 # child commit message contains "melodic" keyword → non-zero score
5756 assert melodic_dim["score"] > 0.0
5757
5758
5759 @pytest.mark.anyio
5760 async def test_commit_detail_diff_summary_unknown_commit_404(
5761 client: AsyncClient,
5762 db_session: AsyncSession,
5763 auth_headers: dict[str, str],
5764 ) -> None:
5765 """Diff summary for unknown commit ID returns 404."""
5766 repo_id, _p, _c = await _seed_commit_detail_fixtures(db_session)
5767 response = await client.get(
5768 f"/api/v1/musehub/repos/{repo_id}/commits/deadbeefdeadbeefdeadbeef/diff-summary",
5769 headers=auth_headers, )
5770 assert response.status_code == 404
5771
5772
5773 # ---------------------------------------------------------------------------
5774 # Commit comment threads — # ---------------------------------------------------------------------------
5775
5776
5777 @pytest.mark.anyio
5778 async def test_commit_page_has_comment_section_html(
5779 client: AsyncClient,
5780 db_session: AsyncSession,
5781 ) -> None:
5782 """Commit detail page HTML includes the HTMX comment target container."""
5783 from datetime import datetime, timezone
5784
5785 repo_id = await _make_repo(db_session)
5786 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5787 commit = MusehubCommit(
5788 commit_id=commit_id,
5789 repo_id=repo_id,
5790 branch="main",
5791 parent_ids=[],
5792 message="Add chorus section",
5793 author="testuser",
5794 timestamp=datetime.now(tz=timezone.utc),
5795 )
5796 db_session.add(commit)
5797 await db_session.commit()
5798
5799 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5800 assert response.status_code == 200
5801 body = response.text
5802 # SSR replaces JS-loaded comment section with a server-rendered HTMX target
5803 assert "commit-comments" in body
5804 assert "hx-target" in body
5805
5806
5807 @pytest.mark.anyio
5808 async def test_commit_page_has_htmx_comment_form(
5809 client: AsyncClient,
5810 db_session: AsyncSession,
5811 ) -> None:
5812 """Commit detail page has an HTMX-driven comment form (replaces old JS comment functions)."""
5813 from datetime import datetime, timezone
5814
5815 repo_id = await _make_repo(db_session)
5816 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5817 commit = MusehubCommit(
5818 commit_id=commit_id,
5819 repo_id=repo_id,
5820 branch="main",
5821 parent_ids=[],
5822 message="Add chorus section",
5823 author="testuser",
5824 timestamp=datetime.now(tz=timezone.utc),
5825 )
5826 db_session.add(commit)
5827 await db_session.commit()
5828
5829 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5830 assert response.status_code == 200
5831 body = response.text
5832 # HTMX form replaces JS renderComments/submitComment/loadComments
5833 assert "hx-post" in body
5834 assert "hx-target" in body
5835 assert "textarea" in body
5836
5837
5838 @pytest.mark.anyio
5839 async def test_commit_page_comment_htmx_target_present(
5840 client: AsyncClient,
5841 db_session: AsyncSession,
5842 ) -> None:
5843 """HTMX comment target div is present for server-side comment injection."""
5844 from datetime import datetime, timezone
5845
5846 repo_id = await _make_repo(db_session)
5847 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5848 commit = MusehubCommit(
5849 commit_id=commit_id,
5850 repo_id=repo_id,
5851 branch="main",
5852 parent_ids=[],
5853 message="Add chorus section",
5854 author="testuser",
5855 timestamp=datetime.now(tz=timezone.utc),
5856 )
5857 db_session.add(commit)
5858 await db_session.commit()
5859
5860 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5861 assert response.status_code == 200
5862 body = response.text
5863 assert 'id="commit-comments"' in body
5864
5865
5866 @pytest.mark.anyio
5867 async def test_commit_page_comment_htmx_posts_to_comments_endpoint(
5868 client: AsyncClient,
5869 db_session: AsyncSession,
5870 ) -> None:
5871 """HTMX form posts to the commit comments endpoint (replaces old JS API fetch)."""
5872 from datetime import datetime, timezone
5873
5874 repo_id = await _make_repo(db_session)
5875 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5876 commit = MusehubCommit(
5877 commit_id=commit_id,
5878 repo_id=repo_id,
5879 branch="main",
5880 parent_ids=[],
5881 message="Add chorus section",
5882 author="testuser",
5883 timestamp=datetime.now(tz=timezone.utc),
5884 )
5885 db_session.add(commit)
5886 await db_session.commit()
5887
5888 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5889 assert response.status_code == 200
5890 body = response.text
5891 assert "hx-post" in body
5892 assert "/comments" in body
5893
5894
5895 @pytest.mark.anyio
5896 async def test_commit_page_comment_has_ssr_avatar(
5897 client: AsyncClient,
5898 db_session: AsyncSession,
5899 ) -> None:
5900 """Commit page SSR comment thread renders avatar initials via server-side template."""
5901 from datetime import datetime, timezone
5902
5903 repo_id = await _make_repo(db_session)
5904 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5905 commit = MusehubCommit(
5906 commit_id=commit_id,
5907 repo_id=repo_id,
5908 branch="main",
5909 parent_ids=[],
5910 message="Add chorus section",
5911 author="testuser",
5912 timestamp=datetime.now(tz=timezone.utc),
5913 )
5914 db_session.add(commit)
5915 await db_session.commit()
5916
5917 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5918 assert response.status_code == 200
5919 body = response.text
5920 # SSR avatar class from commit_comments.html fragment
5921 assert "comment-avatar" in body
5922
5923
5924 @pytest.mark.anyio
5925 async def test_commit_page_comment_has_htmx_form_elements(
5926 client: AsyncClient,
5927 db_session: AsyncSession,
5928 ) -> None:
5929 """Commit page HTMX comment form has textarea and submit button."""
5930 from datetime import datetime, timezone
5931
5932 repo_id = await _make_repo(db_session)
5933 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5934 commit = MusehubCommit(
5935 commit_id=commit_id,
5936 repo_id=repo_id,
5937 branch="main",
5938 parent_ids=[],
5939 message="Add chorus section",
5940 author="testuser",
5941 timestamp=datetime.now(tz=timezone.utc),
5942 )
5943 db_session.add(commit)
5944 await db_session.commit()
5945
5946 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5947 assert response.status_code == 200
5948 body = response.text
5949 # HTMX form replaces old new-comment-form/new-comment-body/comment-submit-btn
5950 assert 'name="body"' in body
5951 assert "btn-primary" in body
5952 assert "Comment" in body
5953
5954
5955 @pytest.mark.anyio
5956 async def test_commit_page_comment_section_shows_count_heading(
5957 client: AsyncClient,
5958 db_session: AsyncSession,
5959 ) -> None:
5960 """Commit page SSR comment section shows a count heading (replaces 'Discussion' heading)."""
5961 from datetime import datetime, timezone
5962
5963 repo_id = await _make_repo(db_session)
5964 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5965 commit = MusehubCommit(
5966 commit_id=commit_id,
5967 repo_id=repo_id,
5968 branch="main",
5969 parent_ids=[],
5970 message="Add chorus section",
5971 author="testuser",
5972 timestamp=datetime.now(tz=timezone.utc),
5973 )
5974 db_session.add(commit)
5975 await db_session.commit()
5976
5977 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5978 assert response.status_code == 200
5979 body = response.text
5980 assert "comment" in body
5981
5982
5983 # ---------------------------------------------------------------------------
5984 # Commit detail enhancements — ref URL links, DB tags in panel, prose
5985 # summary
5986 # ---------------------------------------------------------------------------
5987
5988
5989 @pytest.mark.anyio
5990 async def test_commit_page_ssr_renders_commit_message(
5991 client: AsyncClient,
5992 db_session: AsyncSession,
5993 ) -> None:
5994 """Commit message is rendered server-side (replaces JS ref-tag / tagPill rendering)."""
5995 from datetime import datetime, timezone
5996
5997 repo_id = await _make_repo(db_session)
5998 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5999 commit = MusehubCommit(
6000 commit_id=commit_id,
6001 repo_id=repo_id,
6002 branch="main",
6003 parent_ids=[],
6004 message="Unique groove message XYZ",
6005 author="testuser",
6006 timestamp=datetime.now(tz=timezone.utc),
6007 )
6008 db_session.add(commit)
6009 await db_session.commit()
6010
6011 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
6012 assert response.status_code == 200
6013 body = response.text
6014 # SSR renders commit message directly — no JS tagPill/isRefUrl needed
6015 assert "Unique groove message XYZ" in body
6016
6017
6018 @pytest.mark.anyio
6019 async def test_commit_page_ssr_renders_author_metadata(
6020 client: AsyncClient,
6021 db_session: AsyncSession,
6022 ) -> None:
6023 """Commit author and branch appear in the SSR metadata grid (replaces JS muse-tags panel)."""
6024 from datetime import datetime, timezone
6025
6026 repo_id = await _make_repo(db_session)
6027 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6028 commit = MusehubCommit(
6029 commit_id=commit_id,
6030 repo_id=repo_id,
6031 branch="main",
6032 parent_ids=[],
6033 message="Add chorus section",
6034 author="jazzproducer",
6035 timestamp=datetime.now(tz=timezone.utc),
6036 )
6037 db_session.add(commit)
6038 await db_session.commit()
6039
6040 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
6041 assert response.status_code == 200
6042 body = response.text
6043 # SSR metadata grid shows author — no JS loadMuseTagsPanel needed
6044 assert "jazzproducer" in body
6045
6046
6047 @pytest.mark.anyio
6048 async def test_commit_page_no_audio_shell_when_no_snapshot(
6049 client: AsyncClient,
6050 db_session: AsyncSession,
6051 ) -> None:
6052 """Commit page without snapshot_id omits WaveSurfer shell (replaces buildProseSummary check)."""
6053 from datetime import datetime, timezone
6054
6055 repo_id = await _make_repo(db_session)
6056 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6057 commit = MusehubCommit(
6058 commit_id=commit_id,
6059 repo_id=repo_id,
6060 branch="main",
6061 parent_ids=[],
6062 message="Add chorus section",
6063 author="testuser",
6064 timestamp=datetime.now(tz=timezone.utc),
6065 snapshot_id=None,
6066 )
6067 db_session.add(commit)
6068 await db_session.commit()
6069
6070 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
6071 assert response.status_code == 200
6072 body = response.text
6073 assert "commit-waveform" not in body
6074
6075
6076 # ---------------------------------------------------------------------------
6077 # Audio player — listen page tests
6078 # ---------------------------------------------------------------------------
6079
6080
6081 @pytest.mark.anyio
6082 async def test_listen_page_renders(
6083 client: AsyncClient,
6084 db_session: AsyncSession,
6085 ) -> None:
6086 """GET /musehub/ui/{owner}/{slug}/listen/{ref} must return 200 HTML."""
6087 await _make_repo(db_session)
6088 ref = "abc1234567890abcdef"
6089 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6090 assert response.status_code == 200
6091 assert "text/html" in response.headers["content-type"]
6092
6093
6094 @pytest.mark.anyio
6095 async def test_listen_page_no_auth_required(
6096 client: AsyncClient,
6097 db_session: AsyncSession,
6098 ) -> None:
6099 """Listen page must be accessible without an Authorization header."""
6100 await _make_repo(db_session)
6101 ref = "deadbeef1234"
6102 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6103 assert response.status_code != 401
6104 assert response.status_code == 200
6105
6106
6107 @pytest.mark.anyio
6108 async def test_listen_page_contains_waveform_ui(
6109 client: AsyncClient,
6110 db_session: AsyncSession,
6111 ) -> None:
6112 """Listen page HTML must contain the waveform container element."""
6113 await _make_repo(db_session)
6114 ref = "cafebabe1234"
6115 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6116 assert response.status_code == 200
6117 body = response.text
6118 assert "waveform" in body
6119
6120
6121 @pytest.mark.anyio
6122 async def test_listen_page_contains_play_button(
6123 client: AsyncClient,
6124 db_session: AsyncSession,
6125 ) -> None:
6126 """Listen page must include a play button element."""
6127 await _make_repo(db_session)
6128 ref = "feed1234abcdef"
6129 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6130 assert response.status_code == 200
6131 body = response.text
6132 assert "play-btn" in body
6133
6134
6135 @pytest.mark.anyio
6136 async def test_listen_page_contains_speed_selector(
6137 client: AsyncClient,
6138 db_session: AsyncSession,
6139 ) -> None:
6140 """Listen page must include the playback speed selector element."""
6141 await _make_repo(db_session)
6142 ref = "1a2b3c4d5e6f7890"
6143 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6144 assert response.status_code == 200
6145 body = response.text
6146 assert "speed-sel" in body
6147
6148
6149 @pytest.mark.anyio
6150 async def test_listen_page_contains_ab_loop_ui(
6151 client: AsyncClient,
6152 db_session: AsyncSession,
6153 ) -> None:
6154 """Listen page must include A/B loop controls (loop info + clear button)."""
6155 await _make_repo(db_session)
6156 ref = "aabbccddeeff0011"
6157 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6158 assert response.status_code == 200
6159 body = response.text
6160 assert "loop-info" in body
6161 assert "loop-clear-btn" in body
6162
6163
6164 @pytest.mark.anyio
6165 async def test_listen_page_loads_wavesurfer_vendor(
6166 client: AsyncClient,
6167 db_session: AsyncSession,
6168 ) -> None:
6169 """Listen page must load the vendored wavesurfer.min.js — no external CDN."""
6170 await _make_repo(db_session)
6171 ref = "112233445566778899"
6172 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6173 assert response.status_code == 200
6174 body = response.text
6175 # Must reference the local vendor path — never an external CDN URL
6176 assert "vendor/wavesurfer.min.js" in body
6177 assert "unpkg.com" not in body
6178 assert "cdn.jsdelivr.net" not in body
6179 assert "cdnjs.cloudflare.com" not in body
6180
6181
6182 @pytest.mark.anyio
6183 async def test_listen_page_loads_audio_player_js(
6184 client: AsyncClient,
6185 db_session: AsyncSession,
6186 ) -> None:
6187 """Listen page must load the audio-player.js component wrapper script."""
6188 await _make_repo(db_session)
6189 ref = "99aabbccddeeff00"
6190 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6191 assert response.status_code == 200
6192 body = response.text
6193 assert "audio-player.js" in body
6194
6195
6196 @pytest.mark.anyio
6197 async def test_listen_track_page_renders(
6198 client: AsyncClient,
6199 db_session: AsyncSession,
6200 ) -> None:
6201 """GET /musehub/ui/{owner}/{slug}/listen/{ref}/{path} must return 200."""
6202 await _make_repo(db_session)
6203 ref = "feedface0011aabb"
6204 response = await client.get(
6205 f"/musehub/ui/testuser/test-beats/listen/{ref}/tracks/bass.mp3"
6206 )
6207 assert response.status_code == 200
6208 assert "text/html" in response.headers["content-type"]
6209
6210
6211 @pytest.mark.anyio
6212 async def test_listen_track_page_has_track_path_in_js(
6213 client: AsyncClient,
6214 db_session: AsyncSession,
6215 ) -> None:
6216 """Track path must be injected into the page JS context as TRACK_PATH."""
6217 await _make_repo(db_session)
6218 ref = "00aabbccddeeff11"
6219 track = "tracks/lead-guitar.mp3"
6220 response = await client.get(
6221 f"/musehub/ui/testuser/test-beats/listen/{ref}/{track}"
6222 )
6223 assert response.status_code == 200
6224 body = response.text
6225 assert "TRACK_PATH" in body
6226 assert "lead-guitar.mp3" in body
6227
6228
6229 @pytest.mark.anyio
6230 async def test_listen_page_unknown_repo_404(
6231 client: AsyncClient,
6232 db_session: AsyncSession,
6233 ) -> None:
6234 """GET listen page with nonexistent owner/slug must return 404."""
6235 response = await client.get(
6236 "/musehub/ui/nobody/nonexistent-repo/listen/abc123"
6237 )
6238 assert response.status_code == 404
6239
6240
6241 @pytest.mark.anyio
6242 async def test_listen_page_keyboard_shortcuts_documented(
6243 client: AsyncClient,
6244 db_session: AsyncSession,
6245 ) -> None:
6246 """Listen page must document Space, arrow, and L keyboard shortcuts."""
6247 await _make_repo(db_session)
6248 ref = "cafe0011aabb2233"
6249 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6250 assert response.status_code == 200
6251 body = response.text
6252 # Keyboard hint section must be present
6253 assert "Space" in body or "space" in body.lower()
6254 assert "loop" in body.lower()
6255
6256
6257 # ---------------------------------------------------------------------------
6258 # Compare view
6259 # ---------------------------------------------------------------------------
6260
6261
6262 @pytest.mark.anyio
6263 async def test_compare_page_renders(
6264 client: AsyncClient,
6265 db_session: AsyncSession,
6266 ) -> None:
6267 """GET /musehub/ui/{owner}/{slug}/compare/{base}...{head} returns 200 HTML."""
6268 await _make_repo(db_session)
6269 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6270 assert response.status_code == 200
6271 assert "text/html" in response.headers["content-type"]
6272 body = response.text
6273 assert "Muse Hub" in body
6274 assert "main" in body
6275 assert "feature" in body
6276
6277
6278 @pytest.mark.anyio
6279 async def test_compare_page_no_auth_required(
6280 client: AsyncClient,
6281 db_session: AsyncSession,
6282 ) -> None:
6283 """Compare page is accessible without a JWT token."""
6284 await _make_repo(db_session)
6285 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6286 assert response.status_code == 200
6287
6288
6289 @pytest.mark.anyio
6290 async def test_compare_page_invalid_ref_404(
6291 client: AsyncClient,
6292 db_session: AsyncSession,
6293 ) -> None:
6294 """Compare path without '...' separator returns 404."""
6295 await _make_repo(db_session)
6296 response = await client.get("/musehub/ui/testuser/test-beats/compare/mainfeature")
6297 assert response.status_code == 404
6298
6299
6300 @pytest.mark.anyio
6301 async def test_compare_page_unknown_owner_404(
6302 client: AsyncClient,
6303 ) -> None:
6304 """Unknown owner/slug combination returns 404 on compare page."""
6305 response = await client.get("/musehub/ui/nobody/norepo/compare/main...feature")
6306 assert response.status_code == 404
6307
6308
6309 @pytest.mark.anyio
6310 async def test_compare_page_includes_radar(
6311 client: AsyncClient,
6312 db_session: AsyncSession,
6313 ) -> None:
6314 """Compare page SSR HTML contains all five musical dimension names (replaces JS radar).
6315
6316 The compare page now renders data server-side via a dimension table.
6317 Musical dimensions (Melodic, Harmonic, etc.) must appear in the HTML body
6318 before any client-side JavaScript runs.
6319 """
6320 await _make_repo(db_session)
6321 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6322 assert response.status_code == 200
6323 body = response.text
6324 assert "Melodic" in body
6325 assert "Harmonic" in body
6326
6327
6328 @pytest.mark.anyio
6329 async def test_compare_page_includes_piano_roll(
6330 client: AsyncClient,
6331 db_session: AsyncSession,
6332 ) -> None:
6333 """Compare page SSR HTML contains the dimension table (replaces piano roll JS panel).
6334
6335 The compare page now renders a dimension comparison table server-side.
6336 Both ref names must appear as column headers in the HTML.
6337 """
6338 await _make_repo(db_session)
6339 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6340 assert response.status_code == 200
6341 body = response.text
6342 assert "main" in body
6343 assert "feature" in body
6344 assert "Dimension" in body
6345
6346
6347 @pytest.mark.anyio
6348 async def test_compare_page_includes_emotion_diff(
6349 client: AsyncClient,
6350 db_session: AsyncSession,
6351 ) -> None:
6352 """Compare page SSR HTML contains change delta column (replaces emotion diff JS).
6353
6354 The dimension table includes a Change column showing delta values server-side.
6355 """
6356 await _make_repo(db_session)
6357 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6358 assert response.status_code == 200
6359 body = response.text
6360 assert "Change" in body
6361 assert "%" in body
6362
6363
6364 @pytest.mark.anyio
6365 async def test_compare_page_includes_commit_list(
6366 client: AsyncClient,
6367 db_session: AsyncSession,
6368 ) -> None:
6369 """Compare page SSR HTML contains dimension rows (replaces client-side commit list JS).
6370
6371 All five musical dimensions must appear as data rows in the server-rendered table.
6372 """
6373 await _make_repo(db_session)
6374 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6375 assert response.status_code == 200
6376 body = response.text
6377 assert "Rhythmic" in body
6378 assert "Structural" in body
6379 assert "Dynamic" in body
6380
6381
6382 @pytest.mark.anyio
6383 async def test_compare_page_includes_create_pr_button(
6384 client: AsyncClient,
6385 db_session: AsyncSession,
6386 ) -> None:
6387 """Compare page SSR HTML contains both ref names in the heading (replaces PR button CTA).
6388
6389 The SSR compare page shows the base and head refs in the page header.
6390 """
6391 await _make_repo(db_session)
6392 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6393 assert response.status_code == 200
6394 body = response.text
6395 assert "Compare" in body
6396 assert "main" in body
6397 assert "feature" in body
6398
6399
6400 @pytest.mark.anyio
6401 async def test_compare_json_response(
6402 client: AsyncClient,
6403 db_session: AsyncSession,
6404 ) -> None:
6405 """GET /musehub/ui/{owner}/{slug}/compare/{refs} returns HTML with SSR dimension data.
6406
6407 The compare page is now fully SSR — no JSON format negotiation.
6408 The response is always text/html containing the dimension table.
6409 """
6410 await _make_repo(db_session)
6411 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6412 assert response.status_code == 200
6413 assert "text/html" in response.headers["content-type"]
6414 body = response.text
6415 assert "Melodic" in body
6416 assert "main" in body
6417
6418
6419 # ---------------------------------------------------------------------------
6420 # Issue #208 — Branch list and tag browser tests
6421 # ---------------------------------------------------------------------------
6422
6423
6424 async def _make_repo_with_branches(
6425 db_session: AsyncSession,
6426 ) -> tuple[str, str, str]:
6427 """Seed a repo with two branches (main + feature) and return (repo_id, owner, slug)."""
6428 repo = MusehubRepo(
6429 name="branch-test",
6430 owner="testuser",
6431 slug="branch-test",
6432 visibility="private",
6433 owner_user_id="test-owner",
6434 )
6435 db_session.add(repo)
6436 await db_session.flush()
6437 repo_id = str(repo.repo_id)
6438
6439 main_branch = MusehubBranch(repo_id=repo_id, name="main", head_commit_id="aaa000")
6440 feat_branch = MusehubBranch(repo_id=repo_id, name="feat/jazz-bridge", head_commit_id="bbb111")
6441 db_session.add_all([main_branch, feat_branch])
6442
6443 # Two commits on main, one unique commit on feat/jazz-bridge
6444 now = datetime.now(UTC)
6445 c1 = MusehubCommit(
6446 commit_id="aaa000",
6447 repo_id=repo_id,
6448 branch="main",
6449 parent_ids=[],
6450 message="Initial commit",
6451 author="composer@muse.app",
6452 timestamp=now,
6453 )
6454 c2 = MusehubCommit(
6455 commit_id="aaa001",
6456 repo_id=repo_id,
6457 branch="main",
6458 parent_ids=["aaa000"],
6459 message="Add bridge",
6460 author="composer@muse.app",
6461 timestamp=now,
6462 )
6463 c3 = MusehubCommit(
6464 commit_id="bbb111",
6465 repo_id=repo_id,
6466 branch="feat/jazz-bridge",
6467 parent_ids=["aaa000"],
6468 message="Add jazz chord",
6469 author="composer@muse.app",
6470 timestamp=now,
6471 )
6472 db_session.add_all([c1, c2, c3])
6473 await db_session.commit()
6474 return repo_id, "testuser", "branch-test"
6475
6476
6477 async def _make_repo_with_releases(
6478 db_session: AsyncSession,
6479 ) -> tuple[str, str, str]:
6480 """Seed a repo with namespaced releases used as tags."""
6481 repo = MusehubRepo(
6482 name="tag-test",
6483 owner="testuser",
6484 slug="tag-test",
6485 visibility="private",
6486 owner_user_id="test-owner",
6487 )
6488 db_session.add(repo)
6489 await db_session.flush()
6490 repo_id = str(repo.repo_id)
6491
6492 now = datetime.now(UTC)
6493 releases = [
6494 MusehubRelease(
6495 repo_id=repo_id, tag="emotion:happy", title="Happy vibes", body="",
6496 commit_id="abc001", author="composer", created_at=now, download_urls={},
6497 ),
6498 MusehubRelease(
6499 repo_id=repo_id, tag="genre:jazz", title="Jazz release", body="",
6500 commit_id="abc002", author="composer", created_at=now, download_urls={},
6501 ),
6502 MusehubRelease(
6503 repo_id=repo_id, tag="v1.0", title="Version 1.0", body="",
6504 commit_id="abc003", author="composer", created_at=now, download_urls={},
6505 ),
6506 ]
6507 db_session.add_all(releases)
6508 await db_session.commit()
6509 return repo_id, "testuser", "tag-test"
6510
6511
6512 @pytest.mark.anyio
6513 async def test_branches_page_lists_all(
6514 client: AsyncClient,
6515 db_session: AsyncSession,
6516 ) -> None:
6517 """GET /musehub/ui/{owner}/{slug}/branches returns 200 HTML."""
6518 await _make_repo_with_branches(db_session)
6519 resp = await client.get("/musehub/ui/testuser/branch-test/branches")
6520 assert resp.status_code == 200
6521 assert "text/html" in resp.headers["content-type"]
6522 body = resp.text
6523 assert "Muse Hub" in body
6524 # Page-specific JS identifiers
6525 assert "branch-row" in body or "branches" in body.lower()
6526
6527
6528 @pytest.mark.anyio
6529 async def test_branches_default_marked(
6530 client: AsyncClient,
6531 db_session: AsyncSession,
6532 ) -> None:
6533 """JSON response marks the default branch with isDefault=true."""
6534 await _make_repo_with_branches(db_session)
6535 resp = await client.get(
6536 "/musehub/ui/testuser/branch-test/branches",
6537 headers={"Accept": "application/json"},
6538 )
6539 assert resp.status_code == 200
6540 data = resp.json()
6541 assert "branches" in data
6542 default_branches = [b for b in data["branches"] if b.get("isDefault")]
6543 assert len(default_branches) == 1
6544 assert default_branches[0]["name"] == "main"
6545
6546
6547 @pytest.mark.anyio
6548 async def test_branches_compare_link(
6549 client: AsyncClient,
6550 db_session: AsyncSession,
6551 ) -> None:
6552 """Branches page HTML contains compare link JavaScript."""
6553 await _make_repo_with_branches(db_session)
6554 resp = await client.get("/musehub/ui/testuser/branch-test/branches")
6555 assert resp.status_code == 200
6556 body = resp.text
6557 # The JS template must reference the compare URL pattern
6558 assert "compare" in body.lower()
6559
6560
6561 @pytest.mark.anyio
6562 async def test_branches_new_pr_button(
6563 client: AsyncClient,
6564 db_session: AsyncSession,
6565 ) -> None:
6566 """Branches page HTML contains New Pull Request link JavaScript."""
6567 await _make_repo_with_branches(db_session)
6568 resp = await client.get("/musehub/ui/testuser/branch-test/branches")
6569 assert resp.status_code == 200
6570 body = resp.text
6571 assert "Pull Request" in body
6572
6573
6574 @pytest.mark.anyio
6575 async def test_branches_json_response(
6576 client: AsyncClient,
6577 db_session: AsyncSession,
6578 ) -> None:
6579 """JSON response includes branches with ahead/behind counts and divergence placeholder."""
6580 await _make_repo_with_branches(db_session)
6581 resp = await client.get(
6582 "/musehub/ui/testuser/branch-test/branches?format=json",
6583 )
6584 assert resp.status_code == 200
6585 data = resp.json()
6586 assert "branches" in data
6587 assert "defaultBranch" in data
6588 assert data["defaultBranch"] == "main"
6589
6590 branches_by_name = {b["name"]: b for b in data["branches"]}
6591 assert "main" in branches_by_name
6592 assert "feat/jazz-bridge" in branches_by_name
6593
6594 main = branches_by_name["main"]
6595 assert main["isDefault"] is True
6596 assert main["aheadCount"] == 0
6597 assert main["behindCount"] == 0
6598
6599 feat = branches_by_name["feat/jazz-bridge"]
6600 assert feat["isDefault"] is False
6601 # feat has 1 unique commit (bbb111); main has 2 commits (aaa000, aaa001) not shared with feat
6602 assert feat["aheadCount"] == 1
6603 assert feat["behindCount"] == 2
6604
6605 # Divergence is a placeholder (all None)
6606 div = feat["divergence"]
6607 assert div["melodic"] is None
6608 assert div["harmonic"] is None
6609
6610
6611 @pytest.mark.anyio
6612 async def test_tags_page_lists_all(
6613 client: AsyncClient,
6614 db_session: AsyncSession,
6615 ) -> None:
6616 """GET /musehub/ui/{owner}/{slug}/tags returns 200 HTML."""
6617 await _make_repo_with_releases(db_session)
6618 resp = await client.get("/musehub/ui/testuser/tag-test/tags")
6619 assert resp.status_code == 200
6620 assert "text/html" in resp.headers["content-type"]
6621 body = resp.text
6622 assert "Muse Hub" in body
6623 assert "Tags" in body
6624
6625
6626 @pytest.mark.anyio
6627 async def test_tags_namespace_filter(
6628 client: AsyncClient,
6629 db_session: AsyncSession,
6630 ) -> None:
6631 """Tags page HTML includes namespace filter dropdown JavaScript."""
6632 await _make_repo_with_releases(db_session)
6633 resp = await client.get("/musehub/ui/testuser/tag-test/tags")
6634 assert resp.status_code == 200
6635 body = resp.text
6636 # Namespace filter select element is rendered by JS
6637 assert "ns-filter" in body or "namespace" in body.lower()
6638 # Namespace icons present
6639 assert "&#127768;" in body or "emotion" in body
6640
6641
6642 @pytest.mark.anyio
6643 async def test_tags_json_response(
6644 client: AsyncClient,
6645 db_session: AsyncSession,
6646 ) -> None:
6647 """JSON response returns TagListResponse with namespace grouping."""
6648 await _make_repo_with_releases(db_session)
6649 resp = await client.get(
6650 "/musehub/ui/testuser/tag-test/tags?format=json",
6651 )
6652 assert resp.status_code == 200
6653 data = resp.json()
6654 assert "tags" in data
6655 assert "namespaces" in data
6656
6657 # All three releases become tags
6658 assert len(data["tags"]) == 3
6659
6660 tags_by_name = {t["tag"]: t for t in data["tags"]}
6661 assert "emotion:happy" in tags_by_name
6662 assert "genre:jazz" in tags_by_name
6663 assert "v1.0" in tags_by_name
6664
6665 assert tags_by_name["emotion:happy"]["namespace"] == "emotion"
6666 assert tags_by_name["genre:jazz"]["namespace"] == "genre"
6667 assert tags_by_name["v1.0"]["namespace"] == "version"
6668
6669 # Namespaces are sorted
6670 assert sorted(data["namespaces"]) == data["namespaces"]
6671 assert "emotion" in data["namespaces"]
6672 assert "genre" in data["namespaces"]
6673 assert "version" in data["namespaces"]
6674
6675
6676
6677 # ---------------------------------------------------------------------------
6678 # Arrangement matrix page — # ---------------------------------------------------------------------------
6679
6680
6681 # ---------------------------------------------------------------------------
6682 # Piano roll page tests — # ---------------------------------------------------------------------------
6683
6684
6685 @pytest.mark.anyio
6686 async def test_arrange_page_returns_200(
6687 client: AsyncClient,
6688 db_session: AsyncSession,
6689 ) -> None:
6690 """GET /musehub/ui/{owner}/{slug}/arrange/{ref} returns 200 HTML without a JWT."""
6691 await _make_repo(db_session)
6692 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6693 assert response.status_code == 200
6694 assert "text/html" in response.headers["content-type"]
6695
6696
6697 @pytest.mark.anyio
6698 async def test_piano_roll_page_returns_200(
6699 client: AsyncClient,
6700 db_session: AsyncSession,
6701 ) -> None:
6702 """GET /musehub/ui/{owner}/{slug}/piano-roll/{ref} returns 200 HTML."""
6703 await _make_repo(db_session)
6704 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6705 assert response.status_code == 200
6706 assert "text/html" in response.headers["content-type"]
6707
6708
6709 @pytest.mark.anyio
6710 async def test_arrange_page_no_auth_required(
6711 client: AsyncClient,
6712 db_session: AsyncSession,
6713 ) -> None:
6714 """Arrangement matrix page is accessible without a JWT (auth handled client-side)."""
6715 await _make_repo(db_session)
6716 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6717 assert response.status_code == 200
6718 assert response.status_code != 401
6719
6720
6721 @pytest.mark.anyio
6722 async def test_arrange_page_contains_musehub(
6723 client: AsyncClient,
6724 db_session: AsyncSession,
6725 ) -> None:
6726 """Arrangement matrix page HTML shell contains 'Muse Hub' branding."""
6727 await _make_repo(db_session)
6728 response = await client.get("/musehub/ui/testuser/test-beats/arrange/abc1234")
6729 assert response.status_code == 200
6730 assert "Muse Hub" in response.text
6731
6732
6733 @pytest.mark.anyio
6734 async def test_arrange_page_contains_grid_js(
6735 client: AsyncClient,
6736 db_session: AsyncSession,
6737 ) -> None:
6738 """Arrangement matrix page embeds the grid rendering JS (renderMatrix or arrange)."""
6739 await _make_repo(db_session)
6740 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6741 assert response.status_code == 200
6742 body = response.text
6743 assert "renderMatrix" in body or "arrange" in body.lower()
6744
6745
6746 @pytest.mark.anyio
6747 async def test_arrange_page_contains_density_logic(
6748 client: AsyncClient,
6749 db_session: AsyncSession,
6750 ) -> None:
6751 """Arrangement matrix page includes density colour logic."""
6752 await _make_repo(db_session)
6753 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6754 assert response.status_code == 200
6755 body = response.text
6756 assert "density" in body.lower() or "noteDensity" in body
6757
6758
6759 @pytest.mark.anyio
6760 async def test_arrange_page_contains_token_form(
6761 client: AsyncClient,
6762 db_session: AsyncSession,
6763 ) -> None:
6764 """Arrangement matrix page includes the JWT token form for client-side auth."""
6765 await _make_repo(db_session)
6766 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6767 assert response.status_code == 200
6768 body = response.text
6769 assert 'id="token-form"' in body
6770 assert "musehub.js" in body
6771
6772
6773 @pytest.mark.anyio
6774 async def test_arrange_page_unknown_repo_returns_404(
6775 client: AsyncClient,
6776 db_session: AsyncSession,
6777 ) -> None:
6778 """GET /musehub/ui/{unknown}/{slug}/arrange/{ref} returns 404 for unknown repos."""
6779 response = await client.get("/musehub/ui/unknown-user/no-such-repo/arrange/HEAD")
6780 assert response.status_code == 404
6781
6782
6783 @pytest.mark.anyio
6784 async def test_commit_detail_unknown_format_param_returns_html(
6785 client: AsyncClient,
6786 db_session: AsyncSession,
6787 ) -> None:
6788 """GET commit detail page ignores ?format=json — SSR always returns HTML."""
6789 await _seed_commit_detail_fixtures(db_session)
6790 sha = "bbbb1111222233334444555566667777bbbbcccc"
6791 response = await client.get(
6792 f"/musehub/ui/testuser/commit-detail-test/commits/{sha}?format=json"
6793 )
6794 assert response.status_code == 200
6795 assert "text/html" in response.headers["content-type"]
6796 # SSR commit page — commit message appears in body
6797 assert "feat(keys)" in response.text
6798
6799
6800 @pytest.mark.anyio
6801 async def test_commit_detail_wavesurfer_js_conditional_on_audio_url(
6802 client: AsyncClient,
6803 db_session: AsyncSession,
6804 ) -> None:
6805 """WaveSurfer JS block is only present when audio_url is set (replaces musicalMeta JS checks)."""
6806 await _seed_commit_detail_fixtures(db_session)
6807 sha = "bbbb1111222233334444555566667777bbbbcccc"
6808 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
6809 assert response.status_code == 200
6810 body = response.text
6811 # The child commit has no snapshot_id in _seed_commit_detail_fixtures → no WaveSurfer
6812 assert "commit-waveform" not in body
6813 # WaveSurfer script only loaded when audio is present — not here
6814 assert "wavesurfer.min.js" not in body
6815
6816
6817 @pytest.mark.anyio
6818 async def test_commit_detail_nav_has_parent_link(
6819 client: AsyncClient,
6820 db_session: AsyncSession,
6821 ) -> None:
6822 """Commit detail page navigation includes the parent commit link (SSR)."""
6823 await _seed_commit_detail_fixtures(db_session)
6824 sha = "bbbb1111222233334444555566667777bbbbcccc"
6825 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
6826 assert response.status_code == 200
6827 body = response.text
6828 # SSR renders parent commit link when parent_ids is non-empty
6829 assert "Parent Commit" in body
6830 # Parent SHA abbreviated to 8 chars in href
6831 assert "aaaa0000" in body
6832
6833
6834 @pytest.mark.anyio
6835 async def test_piano_roll_page_no_auth_required(
6836 client: AsyncClient,
6837 db_session: AsyncSession,
6838 ) -> None:
6839 """Piano roll UI page is accessible without a JWT token."""
6840 await _make_repo(db_session)
6841 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6842 assert response.status_code == 200
6843
6844
6845 @pytest.mark.anyio
6846 async def test_piano_roll_page_loads_piano_roll_js(
6847 client: AsyncClient,
6848 db_session: AsyncSession,
6849 ) -> None:
6850 """Piano roll page references piano-roll.js script."""
6851 await _make_repo(db_session)
6852 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6853 assert response.status_code == 200
6854 assert "piano-roll.js" in response.text
6855
6856
6857 @pytest.mark.anyio
6858 async def test_piano_roll_page_contains_canvas(
6859 client: AsyncClient,
6860 db_session: AsyncSession,
6861 ) -> None:
6862 """Piano roll page embeds a canvas element for rendering."""
6863 await _make_repo(db_session)
6864 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6865 assert response.status_code == 200
6866 body = response.text
6867 assert "PianoRoll" in body or "piano-canvas" in body or "piano-roll.js" in body
6868
6869
6870 @pytest.mark.anyio
6871 async def test_piano_roll_page_has_token_form(
6872 client: AsyncClient,
6873 db_session: AsyncSession,
6874 ) -> None:
6875 """Piano roll page includes the JWT token form for unauthenticated visitors."""
6876 await _make_repo(db_session)
6877 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6878 assert response.status_code == 200
6879 assert 'id="token-form"' in response.text
6880 assert "musehub.js" in response.text
6881
6882
6883 @pytest.mark.anyio
6884 async def test_piano_roll_page_unknown_repo_404(
6885 client: AsyncClient,
6886 db_session: AsyncSession,
6887 ) -> None:
6888 """Piano roll page for an unknown repo returns 404."""
6889 response = await client.get("/musehub/ui/nobody/no-repo/piano-roll/main")
6890 assert response.status_code == 404
6891
6892
6893 @pytest.mark.anyio
6894 async def test_arrange_tab_in_repo_nav(
6895 client: AsyncClient,
6896 db_session: AsyncSession,
6897 ) -> None:
6898 """Repo home page navigation includes an 'Arrange' tab link."""
6899 await _make_repo(db_session)
6900 response = await client.get("/musehub/ui/testuser/test-beats")
6901 assert response.status_code == 200
6902 assert "Arrange" in response.text or "arrange" in response.text
6903
6904
6905 @pytest.mark.anyio
6906 async def test_piano_roll_track_page_returns_200(
6907 client: AsyncClient,
6908 db_session: AsyncSession,
6909 ) -> None:
6910 """GET /piano-roll/{ref}/{path} (single track) returns 200."""
6911 await _make_repo(db_session)
6912 response = await client.get(
6913 "/musehub/ui/testuser/test-beats/piano-roll/main/tracks/bass.mid"
6914 )
6915 assert response.status_code == 200
6916
6917
6918 @pytest.mark.anyio
6919 async def test_piano_roll_track_page_embeds_path(
6920 client: AsyncClient,
6921 db_session: AsyncSession,
6922 ) -> None:
6923 """Single-track piano roll page embeds the MIDI file path in the JS context."""
6924 await _make_repo(db_session)
6925 response = await client.get(
6926 "/musehub/ui/testuser/test-beats/piano-roll/main/tracks/bass.mid"
6927 )
6928 assert response.status_code == 200
6929 assert "tracks/bass.mid" in response.text
6930
6931
6932 @pytest.mark.anyio
6933 async def test_piano_roll_js_served(client: AsyncClient) -> None:
6934 """GET /musehub/static/piano-roll.js returns 200 JavaScript."""
6935 response = await client.get("/musehub/static/piano-roll.js")
6936 assert response.status_code == 200
6937 assert "javascript" in response.headers.get("content-type", "")
6938
6939
6940 @pytest.mark.anyio
6941 async def test_piano_roll_js_contains_renderer(client: AsyncClient) -> None:
6942 """piano-roll.js exports the PianoRoll.render function."""
6943 response = await client.get("/musehub/static/piano-roll.js")
6944 assert response.status_code == 200
6945 body = response.text
6946 assert "PianoRoll" in body
6947 assert "render" in body
6948
6949
6950
6951 async def _seed_blob_fixtures(db_session: AsyncSession) -> str:
6952 """Seed a public repo with a branch and typed objects for blob viewer tests.
6953
6954 Creates:
6955 - repo: testuser/blob-test (public)
6956 - branch: main
6957 - objects: tracks/bass.mid, tracks/keys.mp3, metadata.json, cover.webp
6958
6959 Returns repo_id.
6960 """
6961 repo = MusehubRepo(
6962 name="blob-test",
6963 owner="testuser",
6964 slug="blob-test",
6965 visibility="public",
6966 owner_user_id="test-owner",
6967 )
6968 db_session.add(repo)
6969 await db_session.flush()
6970
6971 commit = MusehubCommit(
6972 commit_id="blobdeadbeef12",
6973 repo_id=str(repo.repo_id),
6974 message="add blob fixtures",
6975 branch="main",
6976 author="testuser",
6977 timestamp=datetime.now(tz=UTC),
6978 )
6979 db_session.add(commit)
6980
6981 branch = MusehubBranch(
6982 repo_id=str(repo.repo_id),
6983 name="main",
6984 head_commit_id="blobdeadbeef12",
6985 )
6986 db_session.add(branch)
6987
6988 for path, size in [
6989 ("tracks/bass.mid", 2048),
6990 ("tracks/keys.mp3", 8192),
6991 ("metadata.json", 512),
6992 ("cover.webp", 4096),
6993 ]:
6994 obj = MusehubObject(
6995 object_id=f"sha256:blob_{path.replace('/', '_')}",
6996 repo_id=str(repo.repo_id),
6997 path=path,
6998 size_bytes=size,
6999 disk_path=f"/tmp/blob_{path.replace('/', '_')}",
7000 )
7001 db_session.add(obj)
7002
7003 await db_session.commit()
7004 return str(repo.repo_id)
7005
7006
7007
7008 @pytest.mark.anyio
7009 async def test_blob_404_unknown_path(
7010 client: AsyncClient,
7011 db_session: AsyncSession,
7012 ) -> None:
7013 """GET /api/v1/musehub/repos/{repo_id}/blob/{ref}/{path} returns 404 for unknown path."""
7014 repo_id = await _seed_blob_fixtures(db_session)
7015 response = await client.get(f"/api/v1/musehub/repos/{repo_id}/blob/main/does/not/exist.mid")
7016 assert response.status_code == 404
7017
7018
7019 @pytest.mark.anyio
7020 async def test_blob_image_shows_inline(
7021 client: AsyncClient,
7022 db_session: AsyncSession,
7023 ) -> None:
7024 """Blob page for .webp file includes <img> rendering logic in the template JS."""
7025 await _seed_blob_fixtures(db_session)
7026 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/cover.webp")
7027 assert response.status_code == 200
7028 body = response.text
7029 # JS template emits <img> for image file type
7030 assert "<img" in body or "blob-img" in body
7031 assert "cover.webp" in body
7032
7033
7034 @pytest.mark.anyio
7035 async def test_blob_json_response(
7036 client: AsyncClient,
7037 db_session: AsyncSession,
7038 ) -> None:
7039 """GET /api/v1/musehub/repos/{repo_id}/blob/{ref}/{path} returns BlobMetaResponse JSON."""
7040 repo_id = await _seed_blob_fixtures(db_session)
7041 response = await client.get(
7042 f"/api/v1/musehub/repos/{repo_id}/blob/main/tracks/bass.mid"
7043 )
7044 assert response.status_code == 200
7045 data = response.json()
7046 assert data["path"] == "tracks/bass.mid"
7047 assert data["filename"] == "bass.mid"
7048 assert data["sizeBytes"] == 2048
7049 assert data["fileType"] == "midi"
7050 assert data["sha"].startswith("sha256:")
7051 assert "/raw/" in data["rawUrl"]
7052 # MIDI is binary — no content_text
7053 assert data["contentText"] is None
7054 @pytest.mark.anyio
7055 async def test_blob_json_syntax_highlighted(
7056 client: AsyncClient,
7057 db_session: AsyncSession,
7058 ) -> None:
7059 """Blob page for .json file includes syntax-highlighting logic in the template JS."""
7060 await _seed_blob_fixtures(db_session)
7061 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/metadata.json")
7062 assert response.status_code == 200
7063 body = response.text
7064 # highlightJson function must be present in the template script
7065 assert "highlightJson" in body or "json-key" in body
7066 assert "metadata.json" in body
7067
7068
7069 @pytest.mark.anyio
7070 async def test_blob_midi_shows_piano_roll_link(
7071 client: AsyncClient,
7072 db_session: AsyncSession,
7073 ) -> None:
7074 """GET /musehub/ui/{owner}/{repo}/blob/{ref}/{path} returns 200 HTML for a .mid file.
7075
7076 The template's client-side JS must reference the piano roll URL pattern so that
7077 clicking the page in a browser navigates to the piano roll viewer.
7078 """
7079 await _seed_blob_fixtures(db_session)
7080 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/tracks/bass.mid")
7081 assert response.status_code == 200
7082 assert "text/html" in response.headers["content-type"]
7083 body = response.text
7084 # JS in the template constructs piano-roll URLs for MIDI files
7085 assert "piano-roll" in body or "Piano Roll" in body
7086 # Filename is embedded in the page context
7087 assert "bass.mid" in body
7088
7089
7090 @pytest.mark.anyio
7091 async def test_blob_mp3_shows_audio_player(
7092 client: AsyncClient,
7093 db_session: AsyncSession,
7094 ) -> None:
7095 """Blob page for .mp3 file includes <audio> rendering logic in the template JS."""
7096 await _seed_blob_fixtures(db_session)
7097 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/tracks/keys.mp3")
7098 assert response.status_code == 200
7099 body = response.text
7100 # JS template emits <audio> element for audio file type
7101 assert "<audio" in body or "blob-audio" in body
7102 assert "keys.mp3" in body
7103
7104
7105 @pytest.mark.anyio
7106 async def test_blob_raw_button(
7107 client: AsyncClient,
7108 db_session: AsyncSession,
7109 ) -> None:
7110 """Blob page JS constructs a Raw download link via the /raw/ endpoint."""
7111 await _seed_blob_fixtures(db_session)
7112 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/tracks/bass.mid")
7113 assert response.status_code == 200
7114 body = response.text
7115 # JS constructs raw URL — the string '/raw/' must appear in the template script
7116 assert "/raw/" in body
7117
7118
7119 @pytest.mark.anyio
7120 async def test_score_page_contains_legend(
7121 client: AsyncClient,
7122 db_session: AsyncSession,
7123 ) -> None:
7124 """Score page includes a legend for note symbols."""
7125 await _make_repo(db_session)
7126 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7127 assert response.status_code == 200
7128 body = response.text
7129 assert "legend" in body or "Note" in body
7130
7131
7132 @pytest.mark.anyio
7133 async def test_score_page_contains_score_meta(
7134 client: AsyncClient,
7135 db_session: AsyncSession,
7136 ) -> None:
7137 """Score page embeds a score metadata panel (key/tempo/time signature)."""
7138 await _make_repo(db_session)
7139 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7140 assert response.status_code == 200
7141 body = response.text
7142 assert "score-meta" in body
7143
7144
7145 @pytest.mark.anyio
7146 async def test_score_page_contains_staff_container(
7147 client: AsyncClient,
7148 db_session: AsyncSession,
7149 ) -> None:
7150 """Score page embeds the SVG staff container markup."""
7151 await _make_repo(db_session)
7152 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7153 assert response.status_code == 200
7154 body = response.text
7155 assert "staff-container" in body or "staves" in body
7156
7157
7158 @pytest.mark.anyio
7159 async def test_score_page_contains_track_selector(
7160 client: AsyncClient,
7161 db_session: AsyncSession,
7162 ) -> None:
7163 """Score page embeds a track selector element."""
7164 await _make_repo(db_session)
7165 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7166 assert response.status_code == 200
7167 body = response.text
7168 assert "track-selector" in body
7169
7170
7171 @pytest.mark.anyio
7172 async def test_score_page_no_auth_required(
7173 client: AsyncClient,
7174 db_session: AsyncSession,
7175 ) -> None:
7176 """Score UI page must be accessible without an Authorization header."""
7177 await _make_repo(db_session)
7178 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7179 assert response.status_code == 200
7180 assert response.status_code != 401
7181
7182
7183 @pytest.mark.anyio
7184 async def test_score_page_renders(
7185 client: AsyncClient,
7186 db_session: AsyncSession,
7187 ) -> None:
7188 """GET /musehub/ui/{owner}/{slug}/score/{ref} returns 200 HTML."""
7189 await _make_repo(db_session)
7190 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7191 assert response.status_code == 200
7192 assert "text/html" in response.headers["content-type"]
7193 body = response.text
7194 assert "Muse Hub" in body
7195
7196
7197 @pytest.mark.anyio
7198 async def test_score_part_page_includes_path(
7199 client: AsyncClient,
7200 db_session: AsyncSession,
7201 ) -> None:
7202 """Single-part score page injects the path segment into page data."""
7203 await _make_repo(db_session)
7204 response = await client.get("/musehub/ui/testuser/test-beats/score/main/piano")
7205 assert response.status_code == 200
7206 body = response.text
7207 # scorePath JS variable should be set to the path segment
7208 assert "piano" in body
7209
7210
7211 @pytest.mark.anyio
7212 async def test_score_part_page_renders(
7213 client: AsyncClient,
7214 db_session: AsyncSession,
7215 ) -> None:
7216 """GET /musehub/ui/{owner}/{slug}/score/{ref}/{path} returns 200 HTML."""
7217 await _make_repo(db_session)
7218 response = await client.get("/musehub/ui/testuser/test-beats/score/main/piano")
7219 assert response.status_code == 200
7220 assert "text/html" in response.headers["content-type"]
7221 body = response.text
7222 assert "Muse Hub" in body
7223
7224
7225 @pytest.mark.anyio
7226 async def test_score_unknown_repo_404(
7227 client: AsyncClient,
7228 db_session: AsyncSession,
7229 ) -> None:
7230 """GET /musehub/ui/{unknown}/{slug}/score/{ref} returns 404."""
7231 response = await client.get("/musehub/ui/nobody/no-beats/score/main")
7232 assert response.status_code == 404
7233
7234
7235 # ---------------------------------------------------------------------------
7236 # Arrangement matrix page — # ---------------------------------------------------------------------------
7237
7238
7239 # ---------------------------------------------------------------------------
7240 # Piano roll page tests — # ---------------------------------------------------------------------------
7241
7242
7243 @pytest.mark.anyio
7244 async def test_ui_commit_page_artifact_auth_uses_blob_proxy(
7245 client: AsyncClient,
7246 db_session: AsyncSession,
7247 ) -> None:
7248 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7249
7250 The pre-SSR blob-proxy artifact pattern no longer applies — artifacts are loaded
7251 via the API. Non-existent commit SHAs now return 404 rather than an empty JS shell.
7252 """
7253 await _make_repo(db_session)
7254 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7255 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
7256 assert response.status_code == 404
7257
7258
7259 # ---------------------------------------------------------------------------
7260 # Reaction bars — # ---------------------------------------------------------------------------
7261
7262
7263 @pytest.mark.anyio
7264 async def test_reaction_bar_js_in_musehub_js(
7265 client: AsyncClient,
7266 db_session: AsyncSession,
7267 ) -> None:
7268 """musehub.js must define loadReactions and toggleReaction for all detail pages."""
7269 response = await client.get("/musehub/static/musehub.js")
7270 assert response.status_code == 200
7271 body = response.text
7272 assert "loadReactions" in body
7273 assert "toggleReaction" in body
7274 assert "REACTION_BAR_EMOJIS" in body
7275
7276
7277 @pytest.mark.anyio
7278 async def test_reaction_bar_emojis_in_musehub_js(
7279 client: AsyncClient,
7280 db_session: AsyncSession,
7281 ) -> None:
7282 """musehub.js reaction bar must include all 8 required emojis."""
7283 response = await client.get("/musehub/static/musehub.js")
7284 assert response.status_code == 200
7285 body = response.text
7286 for emoji in ["🔥", "❤️", "👏", "✨", "🎵", "🎸", "🎹", "🥁"]:
7287 assert emoji in body, f"Emoji {emoji!r} missing from musehub.js"
7288
7289
7290 @pytest.mark.anyio
7291 async def test_reaction_bar_commit_page_has_load_call(
7292 client: AsyncClient,
7293 db_session: AsyncSession,
7294 ) -> None:
7295 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7296
7297 Reactions are loaded via the API; the reaction bar is no longer a JS-only element
7298 in the SSR commit_detail.html template. Non-existent commits return 404.
7299 """
7300 await _make_repo(db_session)
7301 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7302 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
7303 assert response.status_code == 404
7304
7305
7306 @pytest.mark.anyio
7307 async def test_reaction_bar_pr_detail_has_load_call(
7308 client: AsyncClient,
7309 db_session: AsyncSession,
7310 ) -> None:
7311 """PR detail page must call loadReactions for target_type 'pull_request'."""
7312 await _make_repo(db_session)
7313 pr_id = "some-pr-uuid-1234"
7314 response = await client.get(f"/musehub/ui/testuser/test-beats/pulls/{pr_id}")
7315 assert response.status_code == 200
7316 body = response.text
7317 assert "loadReactions" in body
7318 assert "pr-reactions" in body
7319
7320
7321 @pytest.mark.anyio
7322 async def test_reaction_bar_issue_detail_has_load_call(
7323 client: AsyncClient,
7324 db_session: AsyncSession,
7325 ) -> None:
7326 """Issue detail page must call loadReactions for target_type 'issue'."""
7327 await _make_repo(db_session)
7328 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
7329 assert response.status_code == 200
7330 body = response.text
7331 assert "loadReactions" in body
7332 assert "issue-reactions" in body
7333
7334
7335 @pytest.mark.anyio
7336 async def test_reaction_bar_release_detail_has_load_call(
7337 client: AsyncClient,
7338 db_session: AsyncSession,
7339 ) -> None:
7340 """Release detail page must call loadReactions for target_type 'release'."""
7341 await _make_repo(db_session)
7342 response = await client.get("/musehub/ui/testuser/test-beats/releases/v1.0")
7343 assert response.status_code == 200
7344 body = response.text
7345 assert "loadReactions" in body
7346 assert "release-reactions" in body
7347
7348
7349 @pytest.mark.anyio
7350 async def test_reaction_bar_session_detail_has_load_call(
7351 client: AsyncClient,
7352 db_session: AsyncSession,
7353 ) -> None:
7354 """Session detail page must call loadReactions for target_type 'session'."""
7355 await _make_repo(db_session)
7356 session_id = "some-session-uuid-1234"
7357 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
7358 assert response.status_code == 200
7359 body = response.text
7360 assert "loadReactions" in body
7361 assert "session-reactions" in body
7362
7363
7364 @pytest.mark.anyio
7365 async def test_reaction_api_allows_new_emojis(
7366 client: AsyncClient,
7367 db_session: AsyncSession,
7368 ) -> None:
7369 """POST /reactions with 👏 and 🎹 (new emojis) must be accepted (not 400)."""
7370 from musehub.db.musehub_models import MusehubRepo
7371 repo = MusehubRepo(
7372 name="reaction-test",
7373 owner="testuser",
7374 slug="reaction-test",
7375 visibility="public",
7376 owner_user_id="reaction-owner",
7377 )
7378 db_session.add(repo)
7379 await db_session.commit()
7380 await db_session.refresh(repo)
7381 repo_id = str(repo.repo_id)
7382
7383 token_headers = {"Authorization": "Bearer test-token"}
7384
7385 for emoji in ["👏", "🎹"]:
7386 response = await client.post(
7387 f"/api/v1/musehub/repos/{repo_id}/reactions",
7388 json={"target_type": "commit", "target_id": "abc123", "emoji": emoji},
7389 headers=token_headers,
7390 )
7391 assert response.status_code not in (400, 422), (
7392 f"Emoji {emoji!r} rejected by API: {response.status_code} {response.text}"
7393 )
7394
7395
7396 @pytest.mark.anyio
7397 async def test_reaction_api_allows_release_and_session_target_types(
7398 client: AsyncClient,
7399 db_session: AsyncSession,
7400 ) -> None:
7401 """POST /reactions must accept 'release' and 'session' as target_type values.
7402
7403 These target types were added to support reaction bars on
7404 release_detail and session_detail pages.
7405 """
7406 from musehub.db.musehub_models import MusehubRepo
7407 repo = MusehubRepo(
7408 name="target-type-test",
7409 owner="testuser",
7410 slug="target-type-test",
7411 visibility="public",
7412 owner_user_id="target-type-owner",
7413 )
7414 db_session.add(repo)
7415 await db_session.commit()
7416 await db_session.refresh(repo)
7417 repo_id = str(repo.repo_id)
7418
7419 token_headers = {"Authorization": "Bearer test-token"}
7420
7421 for target_type in ["release", "session"]:
7422 response = await client.post(
7423 f"/api/v1/musehub/repos/{repo_id}/reactions",
7424 json={"target_type": target_type, "target_id": "some-id", "emoji": "🔥"},
7425 headers=token_headers,
7426 )
7427 assert response.status_code not in (400, 422), (
7428 f"target_type {target_type!r} rejected: {response.status_code} {response.text}"
7429 )
7430
7431
7432 @pytest.mark.anyio
7433 async def test_reaction_bar_css_loaded_on_detail_pages(
7434 client: AsyncClient,
7435 db_session: AsyncSession,
7436 ) -> None:
7437 """Detail pages must load components.css which contains .reaction-bar styles."""
7438 await _make_repo(db_session)
7439 pages = [
7440 "/musehub/ui/testuser/test-beats/pulls/pr-uuid-abc",
7441 "/musehub/ui/testuser/test-beats/issues/1",
7442 "/musehub/ui/testuser/test-beats/releases/v1.0",
7443 "/musehub/ui/testuser/test-beats/sessions/session-uuid-abc",
7444 ]
7445 for page in pages:
7446 response = await client.get(page)
7447 assert response.status_code == 200
7448 assert "components.css" in response.text, f"components.css missing from {page}"
7449
7450
7451 @pytest.mark.anyio
7452 async def test_reaction_bar_components_css_has_styles(
7453 client: AsyncClient,
7454 db_session: AsyncSession,
7455 ) -> None:
7456 """components.css must define .reaction-bar and .reaction-btn CSS classes."""
7457 response = await client.get("/musehub/static/components.css")
7458 assert response.status_code == 200
7459 body = response.text
7460 assert ".reaction-bar" in body
7461 assert ".reaction-btn" in body
7462 assert ".reaction-btn--active" in body
7463 assert ".reaction-count" in body
7464
7465
7466 # ---------------------------------------------------------------------------
7467 # Feed page tests — (rich event cards)
7468 # ---------------------------------------------------------------------------
7469
7470
7471 @pytest.mark.anyio
7472 async def test_feed_page_returns_200(
7473 client: AsyncClient,
7474 db_session: AsyncSession,
7475 ) -> None:
7476 """GET /musehub/ui/feed returns 200 HTML without requiring a JWT."""
7477 response = await client.get("/musehub/ui/feed")
7478 assert response.status_code == 200
7479 assert "text/html" in response.headers["content-type"]
7480 assert "Activity Feed" in response.text
7481
7482
7483 @pytest.mark.anyio
7484 async def test_feed_page_no_raw_json_payload(
7485 client: AsyncClient,
7486 db_session: AsyncSession,
7487 ) -> None:
7488 """Feed page must not render raw JSON.stringify of notification payload.
7489
7490 Regression guard: the old implementation called
7491 JSON.stringify(item.payload) directly into the DOM, exposing raw JSON
7492 to users. The new rich card templates must not do this.
7493 """
7494 response = await client.get("/musehub/ui/feed")
7495 assert response.status_code == 200
7496 body = response.text
7497 assert "JSON.stringify(item.payload" not in body
7498 assert "JSON.stringify(item" not in body
7499
7500
7501 @pytest.mark.anyio
7502 async def test_feed_page_has_event_meta_for_all_types(
7503 client: AsyncClient,
7504 db_session: AsyncSession,
7505 ) -> None:
7506 """Feed page must define EVENT_META entries for all 8 notification event types."""
7507 response = await client.get("/musehub/ui/feed")
7508 assert response.status_code == 200
7509 body = response.text
7510 for event_type in (
7511 "comment",
7512 "mention",
7513 "pr_opened",
7514 "pr_merged",
7515 "issue_opened",
7516 "issue_closed",
7517 "new_commit",
7518 "new_follower",
7519 ):
7520 assert event_type in body, f"EVENT_META missing entry for '{event_type}'"
7521
7522
7523 @pytest.mark.anyio
7524 async def test_feed_page_has_data_notif_id_attribute(
7525 client: AsyncClient,
7526 db_session: AsyncSession,
7527 ) -> None:
7528 """Each event card must carry a data-notif-id attribute.
7529
7530 This attribute is the hook that (mark-as-read UX) will use to
7531 attach action buttons to each card without restructuring the DOM.
7532 """
7533 response = await client.get("/musehub/ui/feed")
7534 assert response.status_code == 200
7535 assert "data-notif-id" in response.text
7536
7537
7538 @pytest.mark.anyio
7539 async def test_feed_page_has_unread_indicator(
7540 client: AsyncClient,
7541 db_session: AsyncSession,
7542 ) -> None:
7543 """Feed page must include logic to highlight unread cards with a left border."""
7544 response = await client.get("/musehub/ui/feed")
7545 assert response.status_code == 200
7546 body = response.text
7547 assert "is_read" in body
7548 assert "color-accent" in body
7549
7550
7551 @pytest.mark.anyio
7552 async def test_feed_page_has_actor_avatar_logic(
7553 client: AsyncClient,
7554 db_session: AsyncSession,
7555 ) -> None:
7556 """Feed page must render actor avatars using the actorHsl / actorAvatar helpers."""
7557 response = await client.get("/musehub/ui/feed")
7558 assert response.status_code == 200
7559 body = response.text
7560 assert "actorHsl" in body
7561 assert "actorAvatar" in body
7562
7563
7564 @pytest.mark.anyio
7565 async def test_feed_page_has_relative_timestamp(
7566 client: AsyncClient,
7567 db_session: AsyncSession,
7568 ) -> None:
7569 """Feed page must call fmtRelative to render timestamps in a human-readable form."""
7570 response = await client.get("/musehub/ui/feed")
7571 assert response.status_code == 200
7572 assert "fmtRelative" in response.text
7573
7574
7575 # ---------------------------------------------------------------------------
7576 # Mark-as-read UX tests — # ---------------------------------------------------------------------------
7577
7578
7579 @pytest.mark.anyio
7580 async def test_feed_page_has_mark_one_read_function(
7581 client: AsyncClient,
7582 db_session: AsyncSession,
7583 ) -> None:
7584 """Feed page must define markOneRead() for per-notification mark-as-read."""
7585 response = await client.get("/musehub/ui/feed")
7586 assert response.status_code == 200
7587 assert "markOneRead" in response.text
7588
7589
7590 @pytest.mark.anyio
7591 async def test_feed_page_has_mark_all_read_function(
7592 client: AsyncClient,
7593 db_session: AsyncSession,
7594 ) -> None:
7595 """Feed page must define markAllRead() for bulk mark-as-read."""
7596 response = await client.get("/musehub/ui/feed")
7597 assert response.status_code == 200
7598 assert "markAllRead" in response.text
7599
7600
7601 @pytest.mark.anyio
7602 async def test_feed_page_has_decrement_nav_badge_function(
7603 client: AsyncClient,
7604 db_session: AsyncSession,
7605 ) -> None:
7606 """Feed page must define decrementNavBadge() to keep the nav badge in sync."""
7607 response = await client.get("/musehub/ui/feed")
7608 assert response.status_code == 200
7609 assert "decrementNavBadge" in response.text
7610
7611
7612 @pytest.mark.anyio
7613 async def test_feed_page_mark_read_btn_targets_notification_endpoint(
7614 client: AsyncClient,
7615 db_session: AsyncSession,
7616 ) -> None:
7617 """markOneRead() must call POST /notifications/{notif_id}/read."""
7618 response = await client.get("/musehub/ui/feed")
7619 assert response.status_code == 200
7620 body = response.text
7621 assert "/notifications/" in body
7622 assert "mark-read-btn" in body
7623
7624
7625 @pytest.mark.anyio
7626 async def test_feed_page_mark_all_btn_targets_read_all_endpoint(
7627 client: AsyncClient,
7628 db_session: AsyncSession,
7629 ) -> None:
7630 """markAllRead() must call POST /notifications/read-all."""
7631 response = await client.get("/musehub/ui/feed")
7632 assert response.status_code == 200
7633 assert "read-all" in response.text
7634
7635
7636 @pytest.mark.anyio
7637 async def test_feed_page_mark_all_btn_present_in_template(
7638 client: AsyncClient,
7639 db_session: AsyncSession,
7640 ) -> None:
7641 """Feed page must render a 'Mark all as read' button element."""
7642 response = await client.get("/musehub/ui/feed")
7643 assert response.status_code == 200
7644 assert "mark-all-read-btn" in response.text
7645
7646
7647 @pytest.mark.anyio
7648 async def test_feed_page_mark_read_updates_nav_badge(
7649 client: AsyncClient,
7650 db_session: AsyncSession,
7651 ) -> None:
7652 """After marking all as read, page logic must update nav-notif-badge to hidden."""
7653 response = await client.get("/musehub/ui/feed")
7654 assert response.status_code == 200
7655 body = response.text
7656 assert "nav-notif-badge" in body
7657 assert "decrementNavBadge" in body
7658
7659
7660 # ---------------------------------------------------------------------------
7661 # Per-dimension analysis detail pages
7662 # ---------------------------------------------------------------------------
7663
7664
7665 @pytest.mark.anyio
7666 async def test_key_analysis_page_renders(
7667 client: AsyncClient,
7668 db_session: AsyncSession,
7669 ) -> None:
7670 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/key returns 200 HTML."""
7671 await _make_repo(db_session)
7672 ref = "abc1234567890abcdef"
7673 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/key")
7674 assert response.status_code == 200
7675 assert "text/html" in response.headers["content-type"]
7676
7677
7678 @pytest.mark.anyio
7679 async def test_key_analysis_page_no_auth_required(
7680 client: AsyncClient,
7681 db_session: AsyncSession,
7682 ) -> None:
7683 """Key analysis page must be accessible without a JWT (HTML shell handles auth)."""
7684 await _make_repo(db_session)
7685 ref = "deadbeef1234"
7686 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/key")
7687 assert response.status_code != 401
7688 assert response.status_code == 200
7689
7690
7691 @pytest.mark.anyio
7692 async def test_key_analysis_page_contains_key_data_labels(
7693 client: AsyncClient,
7694 db_session: AsyncSession,
7695 ) -> None:
7696 """Key page must contain tonic, mode, relative key, and confidence UI elements."""
7697 await _make_repo(db_session)
7698 ref = "cafebabe12345678"
7699 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/key")
7700 assert response.status_code == 200
7701 body = response.text
7702 assert "Key Detection" in body
7703 assert "Relative Key" in body
7704 assert "Detection Confidence" in body
7705 assert "Alternate Key" in body
7706
7707
7708 @pytest.mark.anyio
7709 async def test_meter_analysis_page_renders(
7710 client: AsyncClient,
7711 db_session: AsyncSession,
7712 ) -> None:
7713 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/meter returns 200 HTML."""
7714 await _make_repo(db_session)
7715 ref = "abc1234567890abcdef"
7716 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/meter")
7717 assert response.status_code == 200
7718 assert "text/html" in response.headers["content-type"]
7719
7720
7721 @pytest.mark.anyio
7722 async def test_meter_analysis_page_no_auth_required(
7723 client: AsyncClient,
7724 db_session: AsyncSession,
7725 ) -> None:
7726 """Meter analysis page must be accessible without a JWT (HTML shell handles auth)."""
7727 await _make_repo(db_session)
7728 ref = "deadbeef5678"
7729 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/meter")
7730 assert response.status_code != 401
7731 assert response.status_code == 200
7732
7733
7734 @pytest.mark.anyio
7735 async def test_meter_analysis_page_contains_meter_data_labels(
7736 client: AsyncClient,
7737 db_session: AsyncSession,
7738 ) -> None:
7739 """Meter page must contain time signature, compound/simple badge, and beat strength UI."""
7740 await _make_repo(db_session)
7741 ref = "feedface5678"
7742 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/meter")
7743 assert response.status_code == 200
7744 body = response.text
7745 assert "Meter Analysis" in body
7746 assert "Time Signature" in body
7747 assert "Beat Strength Profile" in body
7748 # SSR migration (issue #578): beat strength is now rendered as inline CSS bars,
7749 # not as a JS function call. Verify the label is present and CSS bars are rendered.
7750 assert "border-radius" in body or "%" in body
7751
7752
7753 @pytest.mark.anyio
7754 async def test_chord_map_analysis_page_renders(
7755 client: AsyncClient,
7756 db_session: AsyncSession,
7757 ) -> None:
7758 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/chord-map returns 200 HTML."""
7759 await _make_repo(db_session)
7760 ref = "abc1234567890abcdef"
7761 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/chord-map")
7762 assert response.status_code == 200
7763 assert "text/html" in response.headers["content-type"]
7764
7765
7766 @pytest.mark.anyio
7767 async def test_chord_map_analysis_page_no_auth_required(
7768 client: AsyncClient,
7769 db_session: AsyncSession,
7770 ) -> None:
7771 """Chord-map analysis page must be accessible without a JWT (HTML shell handles auth)."""
7772 await _make_repo(db_session)
7773 ref = "deadbeef9999"
7774 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/chord-map")
7775 assert response.status_code != 401
7776 assert response.status_code == 200
7777
7778
7779 @pytest.mark.anyio
7780 async def test_chord_map_analysis_page_contains_chord_data_labels(
7781 client: AsyncClient,
7782 db_session: AsyncSession,
7783 ) -> None:
7784 """Chord-map page SSR: must contain progression timeline, chord table, and tension data."""
7785 await _make_repo(db_session)
7786 ref = "beefdead1234"
7787 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/chord-map")
7788 assert response.status_code == 200
7789 body = response.text
7790 assert "Chord Map" in body
7791 assert "PROGRESSION TIMELINE" in body
7792 assert "CHORD TABLE" in body
7793 assert "tension" in body.lower()
7794
7795
7796 @pytest.mark.anyio
7797 async def test_groove_analysis_page_renders(
7798 client: AsyncClient,
7799 db_session: AsyncSession,
7800 ) -> None:
7801 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/groove returns 200 HTML."""
7802 await _make_repo(db_session)
7803 ref = "abc1234567890abcdef"
7804 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/groove")
7805 assert response.status_code == 200
7806 assert "text/html" in response.headers["content-type"]
7807
7808
7809 @pytest.mark.anyio
7810 async def test_groove_analysis_page_no_auth_required(
7811 client: AsyncClient,
7812 db_session: AsyncSession,
7813 ) -> None:
7814 """Groove analysis page must be accessible without a JWT (HTML shell handles auth)."""
7815 await _make_repo(db_session)
7816 ref = "deadbeef4321"
7817 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/groove")
7818 assert response.status_code != 401
7819 assert response.status_code == 200
7820
7821
7822 @pytest.mark.anyio
7823 async def test_groove_analysis_page_contains_groove_data_labels(
7824 client: AsyncClient,
7825 db_session: AsyncSession,
7826 ) -> None:
7827 """Groove page must contain style badge, BPM, swing factor, and groove score UI."""
7828 await _make_repo(db_session)
7829 ref = "cafefeed5678"
7830 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/groove")
7831 assert response.status_code == 200
7832 body = response.text
7833 assert "Groove Analysis" in body
7834 assert "Style" in body
7835 assert "BPM" in body
7836 assert "Groove Score" in body
7837 assert "Swing Factor" in body
7838
7839
7840 @pytest.mark.anyio
7841 async def test_emotion_analysis_page_renders(
7842 client: AsyncClient,
7843 db_session: AsyncSession,
7844 ) -> None:
7845 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/emotion returns 200 HTML."""
7846 await _make_repo(db_session)
7847 ref = "abc1234567890abcdef"
7848 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/emotion")
7849 assert response.status_code == 200
7850 assert "text/html" in response.headers["content-type"]
7851
7852
7853 @pytest.mark.anyio
7854 async def test_emotion_analysis_page_no_auth_required(
7855 client: AsyncClient,
7856 db_session: AsyncSession,
7857 ) -> None:
7858 """Emotion analysis page must be accessible without a JWT (HTML shell handles auth)."""
7859 await _make_repo(db_session)
7860 ref = "deadbeef0001"
7861 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/emotion")
7862 assert response.status_code != 401
7863 assert response.status_code == 200
7864
7865
7866 @pytest.mark.anyio
7867 async def test_emotion_analysis_page_contains_emotion_data_labels(
7868 client: AsyncClient,
7869 db_session: AsyncSession,
7870 ) -> None:
7871 """Emotion page SSR: must contain SVG scatter plot and summary vector dimension bars."""
7872 await _make_repo(db_session)
7873 ref = "aabbccdd5678"
7874 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/emotion")
7875 assert response.status_code == 200
7876 body = response.text
7877 assert "Emotion Analysis" in body
7878 assert "SUMMARY VECTOR" in body
7879 assert "Valence" in body or "valence" in body
7880 assert "Tension" in body or "tension" in body
7881 assert "<circle" in body or "<svg" in body
7882
7883
7884 @pytest.mark.anyio
7885 async def test_form_analysis_page_renders(
7886 client: AsyncClient,
7887 db_session: AsyncSession,
7888 ) -> None:
7889 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/form returns 200 HTML."""
7890 await _make_repo(db_session)
7891 ref = "abc1234567890abcdef"
7892 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/form")
7893 assert response.status_code == 200
7894 assert "text/html" in response.headers["content-type"]
7895
7896
7897 @pytest.mark.anyio
7898 async def test_form_analysis_page_no_auth_required(
7899 client: AsyncClient,
7900 db_session: AsyncSession,
7901 ) -> None:
7902 """Form analysis page must be accessible without a JWT (HTML shell handles auth)."""
7903 await _make_repo(db_session)
7904 ref = "deadbeef0002"
7905 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/form")
7906 assert response.status_code != 401
7907 assert response.status_code == 200
7908
7909
7910 @pytest.mark.anyio
7911 async def test_form_analysis_page_contains_form_data_labels(
7912 client: AsyncClient,
7913 db_session: AsyncSession,
7914 ) -> None:
7915 """Form page must contain form label, section timeline, and sections table."""
7916 await _make_repo(db_session)
7917 ref = "11223344abcd"
7918 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/form")
7919 assert response.status_code == 200
7920 body = response.text
7921 assert "Form Analysis" in body
7922 assert "Form Timeline" in body or "formLabel" in body
7923 assert "Sections" in body
7924 assert "Total Beats" in body
7925
7926
7927 # ---------------------------------------------------------------------------
7928 # Issue #295 — Profile page: followers/following lists with user cards
7929 # ---------------------------------------------------------------------------
7930
7931 # test_profile_page_has_followers_following_tabs
7932 # test_profile_page_has_user_card_js
7933 # test_profile_page_has_switch_tab_js
7934 # test_followers_list_endpoint_returns_200
7935 # test_followers_list_returns_user_cards_for_known_user
7936 # test_following_list_returns_user_cards_for_known_user
7937 # test_followers_list_unknown_user_404
7938 # test_following_list_unknown_user_404
7939 # test_followers_response_includes_following_count
7940 # test_followers_list_empty_for_user_with_no_followers
7941
7942
7943 async def _make_follow(
7944 db_session: AsyncSession,
7945 follower_id: str,
7946 followee_id: str,
7947 ) -> MusehubFollow:
7948 """Seed a follow relationship and return the ORM row."""
7949 import uuid
7950 row = MusehubFollow(
7951 follow_id=str(uuid.uuid4()),
7952 follower_id=follower_id,
7953 followee_id=followee_id,
7954 )
7955 db_session.add(row)
7956 await db_session.commit()
7957 return row
7958
7959
7960 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
7961 @pytest.mark.anyio
7962 async def test_profile_page_has_followers_following_tabs(
7963 client: AsyncClient,
7964 db_session: AsyncSession,
7965 ) -> None:
7966 """Profile page must render Followers and Following tab buttons."""
7967 await _make_profile(db_session, username="tabuser")
7968 response = await client.get("/musehub/ui/users/tabuser")
7969 assert response.status_code == 200
7970 body = response.text
7971 assert "tab-btn-followers" in body
7972 assert "tab-btn-following" in body
7973
7974
7975 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
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