gabriel / musehub public
test_musehub_ui.py python
8610 lines 291.5 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d 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="Pre-existing failure: profile page template missing loadForkedRepos JS")
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="Pre-existing failure: profile page template missing loadStarredRepos JS")
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="Pre-existing failure: profile page template missing loadWatchedRepos JS")
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/{repo_id}/sessions/{session_id} returns 200 HTML."""
3006 repo_id = await _make_repo(db_session)
3007 session_id = "some-session-uuid-1234"
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 "Recording 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 HTML includes the Participants section."""
3023 repo_id = await _make_repo(db_session)
3024 session_id = "participant-session-5678"
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
3030
3031 @pytest.mark.anyio
3032 async def test_session_detail_commits(
3033 client: AsyncClient,
3034 db_session: AsyncSession,
3035 ) -> None:
3036 """Session detail page HTML includes the Commits section."""
3037 repo_id = await _make_repo(db_session)
3038 session_id = "commits-session-9012"
3039 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3040 assert response.status_code == 200
3041 body = response.text
3042 assert "Commits" in body
3043
3044
3045 @pytest.mark.anyio
3046 async def test_session_detail_404_marker(
3047 client: AsyncClient,
3048 db_session: AsyncSession,
3049 ) -> None:
3050 """Session detail page renders a 404 error message for unknown session IDs.
3051
3052 The page itself returns 200 (HTML shell) — the 404 is detected client-side
3053 when the JS calls the JSON API. The page must include error-handling JS that
3054 checks for a 404 response and shows a user-friendly message.
3055 """
3056 repo_id = await _make_repo(db_session)
3057 session_id = "does-not-exist-1234"
3058 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3059 assert response.status_code == 200
3060 body = response.text
3061 # The JS error handler must check for a 404 and render a "not found" message
3062 assert "Session not found" in body or "404" in body
3063
3064
3065 @pytest.mark.anyio
3066 async def test_session_detail_has_comment_section(
3067 client: AsyncClient,
3068 db_session: AsyncSession,
3069 ) -> None:
3070 """Session detail page must include the Discussion comment section."""
3071 await _make_repo(db_session)
3072 session_id = "comment-section-session-001"
3073 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3074 assert response.status_code == 200
3075 body = response.text
3076 assert "comments-section" in body
3077 assert "Discussion" in body
3078 assert "comments-list" in body
3079
3080
3081 @pytest.mark.anyio
3082 async def test_session_detail_comment_section_uses_session_target_type(
3083 client: AsyncClient,
3084 db_session: AsyncSession,
3085 ) -> None:
3086 """Session detail comment API calls must use target_type='session'."""
3087 await _make_repo(db_session)
3088 session_id = "target-type-session-002"
3089 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3090 assert response.status_code == 200
3091 body = response.text
3092 assert "target_type: 'session'" in body or "target_type=\"session\"" in body
3093
3094
3095 @pytest.mark.anyio
3096 async def test_session_detail_has_loadcomments_call(
3097 client: AsyncClient,
3098 db_session: AsyncSession,
3099 ) -> None:
3100 """Session detail page must call loadComments() on init."""
3101 await _make_repo(db_session)
3102 session_id = "load-comments-session-003"
3103 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3104 assert response.status_code == 200
3105 body = response.text
3106 assert "loadComments" in body
3107
3108
3109 @pytest.mark.anyio
3110 async def test_session_detail_has_submit_comment_function(
3111 client: AsyncClient,
3112 db_session: AsyncSession,
3113 ) -> None:
3114 """Session detail page must include the submitComment JS function."""
3115 await _make_repo(db_session)
3116 session_id = "submit-comment-session-004"
3117 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3118 assert response.status_code == 200
3119 body = response.text
3120 assert "submitComment" in body
3121 assert "deleteComment" in body
3122
3123
3124 @pytest.mark.anyio
3125 async def test_session_detail_has_render_comments_function(
3126 client: AsyncClient,
3127 db_session: AsyncSession,
3128 ) -> None:
3129 """Session detail page must include renderComments() for threaded display."""
3130 await _make_repo(db_session)
3131 session_id = "render-comments-session-005"
3132 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3133 assert response.status_code == 200
3134 body = response.text
3135 assert "renderComments" in body
3136
3137
3138 @pytest.mark.anyio
3139 async def test_session_detail_comment_form_has_new_comment_body(
3140 client: AsyncClient,
3141 db_session: AsyncSession,
3142 ) -> None:
3143 """Session detail page must include the new-comment-body textarea and submit button."""
3144 await _make_repo(db_session)
3145 session_id = "comment-form-session-006"
3146 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
3147 assert response.status_code == 200
3148 body = response.text
3149 assert "new-comment-body" in body
3150 assert "new-comment-form" in body
3151 assert "comment-submit-btn" in body
3152
3153
3154 async def _make_session(
3155 db_session: AsyncSession,
3156 repo_id: str,
3157 *,
3158 started_offset_seconds: int = 0,
3159 is_active: bool = False,
3160 intent: str = "jazz composition",
3161 participants: list[str] | None = None,
3162 commits: list[str] | None = None,
3163 notes: str = "",
3164 ) -> str:
3165 """Seed a MusehubSession and return its session_id."""
3166 start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
3167 from datetime import timedelta
3168
3169 started_at = start + timedelta(seconds=started_offset_seconds)
3170 ended_at = None if is_active else started_at + timedelta(hours=1)
3171 row = MusehubSession(
3172 repo_id=repo_id,
3173 started_at=started_at,
3174 ended_at=ended_at,
3175 participants=participants or ["producer-a"],
3176 commits=commits or [],
3177 notes=notes,
3178 intent=intent,
3179 location="Studio A",
3180 is_active=is_active,
3181 )
3182 db_session.add(row)
3183 await db_session.commit()
3184 await db_session.refresh(row)
3185 return str(row.session_id)
3186
3187
3188 @pytest.mark.anyio
3189 async def test_sessions_json_response(
3190 client: AsyncClient,
3191 db_session: AsyncSession,
3192 auth_headers: dict[str, str],
3193 ) -> None:
3194 """GET /api/v1/musehub/repos/{repo_id}/sessions returns session list with metadata."""
3195 repo_id = await _make_repo(db_session)
3196 session_id = await _make_session(db_session, repo_id, intent="jazz solo")
3197
3198 response = await client.get(
3199 f"/api/v1/musehub/repos/{repo_id}/sessions",
3200 headers=auth_headers,
3201 )
3202 assert response.status_code == 200
3203 data = response.json()
3204 assert "sessions" in data
3205 assert "total" in data
3206 assert data["total"] == 1
3207 sess = data["sessions"][0]
3208 assert sess["sessionId"] == session_id
3209 assert sess["intent"] == "jazz solo"
3210 assert sess["location"] == "Studio A"
3211 assert sess["isActive"] is False
3212 assert sess["durationSeconds"] == pytest.approx(3600.0)
3213
3214
3215 @pytest.mark.anyio
3216 async def test_sessions_newest_first(
3217 client: AsyncClient,
3218 db_session: AsyncSession,
3219 auth_headers: dict[str, str],
3220 ) -> None:
3221 """Sessions are returned newest-first (active sessions appear before ended sessions)."""
3222 repo_id = await _make_repo(db_session)
3223 # older ended session
3224 await _make_session(db_session, repo_id, started_offset_seconds=0, intent="older")
3225 # newer ended session
3226 await _make_session(db_session, repo_id, started_offset_seconds=3600, intent="newer")
3227 # active session (should surface first regardless of time)
3228 await _make_session(
3229 db_session, repo_id, started_offset_seconds=100, is_active=True, intent="live"
3230 )
3231
3232 response = await client.get(
3233 f"/api/v1/musehub/repos/{repo_id}/sessions",
3234 headers=auth_headers,
3235 )
3236 assert response.status_code == 200
3237 sessions = response.json()["sessions"]
3238 assert len(sessions) == 3
3239 # Active session must come first
3240 assert sessions[0]["isActive"] is True
3241 assert sessions[0]["intent"] == "live"
3242 # Then newest ended session
3243 assert sessions[1]["intent"] == "newer"
3244 assert sessions[2]["intent"] == "older"
3245
3246
3247 @pytest.mark.anyio
3248 async def test_sessions_empty_for_new_repo(
3249 client: AsyncClient,
3250 db_session: AsyncSession,
3251 auth_headers: dict[str, str],
3252 ) -> None:
3253 """GET /api/v1/musehub/repos/{repo_id}/sessions returns empty list for new repo."""
3254 repo_id = await _make_repo(db_session)
3255 response = await client.get(
3256 f"/api/v1/musehub/repos/{repo_id}/sessions",
3257 headers=auth_headers,
3258 )
3259 assert response.status_code == 200
3260 data = response.json()
3261 assert data["sessions"] == []
3262 assert data["total"] == 0
3263
3264
3265 @pytest.mark.anyio
3266 async def test_sessions_requires_auth(
3267 client: AsyncClient,
3268 db_session: AsyncSession,
3269 ) -> None:
3270 """GET /api/v1/musehub/repos/{repo_id}/sessions returns 401 without auth."""
3271 repo_id = await _make_repo(db_session)
3272 response = await client.get(f"/api/v1/musehub/repos/{repo_id}/sessions")
3273 assert response.status_code == 401
3274
3275
3276 @pytest.mark.anyio
3277 async def test_sessions_404_for_unknown_repo(
3278 client: AsyncClient,
3279 db_session: AsyncSession,
3280 auth_headers: dict[str, str],
3281 ) -> None:
3282 """GET /api/v1/musehub/repos/{unknown}/sessions returns 404."""
3283 response = await client.get(
3284 "/api/v1/musehub/repos/does-not-exist/sessions",
3285 headers=auth_headers,
3286 )
3287 assert response.status_code == 404
3288
3289
3290 @pytest.mark.anyio
3291 async def test_create_session_returns_201(
3292 client: AsyncClient,
3293 db_session: AsyncSession,
3294 auth_headers: dict[str, str],
3295 ) -> None:
3296 """POST /api/v1/musehub/repos/{repo_id}/sessions creates a session and returns 201."""
3297 repo_id = await _make_repo(db_session)
3298 payload = {
3299 "participants": ["producer-a", "collab-b"],
3300 "intent": "house beat experiment",
3301 "location": "Remote – Berlin",
3302 "isActive": True,
3303 }
3304 response = await client.post(
3305 f"/api/v1/musehub/repos/{repo_id}/sessions",
3306 json=payload,
3307 headers=auth_headers,
3308 )
3309 assert response.status_code == 201
3310 data = response.json()
3311 assert data["isActive"] is True
3312 assert data["intent"] == "house beat experiment"
3313 assert data["location"] == "Remote \u2013 Berlin"
3314 assert data["participants"] == ["producer-a", "collab-b"]
3315 assert "sessionId" in data
3316
3317
3318 @pytest.mark.anyio
3319 async def test_stop_session_marks_ended(
3320 client: AsyncClient,
3321 db_session: AsyncSession,
3322 auth_headers: dict[str, str],
3323 ) -> None:
3324 """POST /api/v1/musehub/repos/{repo_id}/sessions/{session_id}/stop closes a live session."""
3325 repo_id = await _make_repo(db_session)
3326 session_id = await _make_session(db_session, repo_id, is_active=True)
3327
3328 response = await client.post(
3329 f"/api/v1/musehub/repos/{repo_id}/sessions/{session_id}/stop",
3330 json={},
3331 headers=auth_headers,
3332 )
3333 assert response.status_code == 200
3334 data = response.json()
3335 assert data["isActive"] is False
3336 assert data["endedAt"] is not None
3337 assert data["durationSeconds"] is not None
3338
3339
3340 @pytest.mark.anyio
3341 async def test_active_session_has_null_duration(
3342 client: AsyncClient,
3343 db_session: AsyncSession,
3344 auth_headers: dict[str, str],
3345 ) -> None:
3346 """Active sessions must have durationSeconds=null (session still in progress)."""
3347 repo_id = await _make_repo(db_session)
3348 await _make_session(db_session, repo_id, is_active=True)
3349
3350 response = await client.get(
3351 f"/api/v1/musehub/repos/{repo_id}/sessions",
3352 headers=auth_headers,
3353 )
3354 assert response.status_code == 200
3355 sess = response.json()["sessions"][0]
3356 assert sess["isActive"] is True
3357 assert sess["durationSeconds"] is None
3358
3359
3360 @pytest.mark.anyio
3361 async def test_session_response_includes_commits_and_notes(
3362 client: AsyncClient,
3363 db_session: AsyncSession,
3364 auth_headers: dict[str, str],
3365 ) -> None:
3366 """SessionResponse includes commits list and notes field in the JSON payload."""
3367 repo_id = await _make_repo(db_session)
3368 commit_ids = ["abc123", "def456", "ghi789"]
3369 closing_notes = "Great session, nailed the groove."
3370 await _make_session(
3371 db_session,
3372 repo_id,
3373 intent="funk groove",
3374 commits=commit_ids,
3375 notes=closing_notes,
3376 )
3377
3378 response = await client.get(
3379 f"/api/v1/musehub/repos/{repo_id}/sessions",
3380 headers=auth_headers,
3381 )
3382 assert response.status_code == 200
3383 sess = response.json()["sessions"][0]
3384 assert sess["commits"] == commit_ids
3385 assert sess["notes"] == closing_notes
3386
3387
3388 @pytest.mark.anyio
3389 async def test_session_response_commits_field_present(
3390 client: AsyncClient,
3391 db_session: AsyncSession,
3392 auth_headers: dict[str, str],
3393 ) -> None:
3394 """Sessions API response includes the 'commits' field for each session.
3395
3396 Regression guard: the graph page uses the session commits
3397 list to build the session→commit index (buildSessionMap). If this field
3398 is absent or empty when commits exist, no session rings will appear on
3399 the DAG graph.
3400 """
3401 repo_id = await _make_repo(db_session)
3402 commit_ids = ["abc123def456abc123def456abc123de", "feedbeeffeedbeefdead000000000001"]
3403 row = MusehubSession(
3404 repo_id=repo_id,
3405 started_at=datetime(2025, 3, 1, 10, 0, 0, tzinfo=timezone.utc),
3406 ended_at=datetime(2025, 3, 1, 11, 0, 0, tzinfo=timezone.utc),
3407 participants=["artist-a"],
3408 intent="session with commits",
3409 location="Studio B",
3410 is_active=False,
3411 commits=commit_ids,
3412 )
3413 db_session.add(row)
3414 await db_session.commit()
3415 await db_session.refresh(row)
3416
3417 response = await client.get(
3418 f"/api/v1/musehub/repos/{repo_id}/sessions",
3419 headers=auth_headers,
3420 )
3421 assert response.status_code == 200
3422 sessions = response.json()["sessions"]
3423 assert len(sessions) == 1
3424 sess = sessions[0]
3425 assert "commits" in sess, "'commits' field missing from SessionResponse"
3426 assert sess["commits"] == commit_ids, "commits field does not match seeded commit IDs"
3427
3428
3429 @pytest.mark.anyio
3430 async def test_session_response_empty_commits_and_notes_defaults(
3431 client: AsyncClient,
3432 db_session: AsyncSession,
3433 auth_headers: dict[str, str],
3434 ) -> None:
3435 """SessionResponse defaults commits to [] and notes to '' when absent."""
3436 repo_id = await _make_repo(db_session)
3437 await _make_session(db_session, repo_id, intent="defaults check")
3438
3439 response = await client.get(
3440 f"/api/v1/musehub/repos/{repo_id}/sessions",
3441 headers=auth_headers,
3442 )
3443 assert response.status_code == 200
3444 sess = response.json()["sessions"][0]
3445 assert sess["commits"] == []
3446 assert sess["notes"] == ""
3447
3448
3449 @pytest.mark.anyio
3450 async def test_session_list_page_contains_avatar_markup(
3451 client: AsyncClient,
3452 db_session: AsyncSession,
3453 ) -> None:
3454 """Sessions list page HTML contains participant avatar JS and CSS class references."""
3455 repo_id = await _make_repo(db_session)
3456 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3457 assert response.status_code == 200
3458 body = response.text
3459 # The JS helper that builds avatar stacks must be present in the page
3460 assert "participant-stack" in body
3461 assert "participant-avatar" in body
3462 assert "strHsl" in body
3463
3464
3465 @pytest.mark.anyio
3466 async def test_session_list_page_contains_commit_pill_markup(
3467 client: AsyncClient,
3468 db_session: AsyncSession,
3469 ) -> None:
3470 """Sessions list page HTML contains commit count pill JS reference."""
3471 repo_id = await _make_repo(db_session)
3472 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3473 assert response.status_code == 200
3474 body = response.text
3475 assert "session-commit-pill" in body
3476
3477
3478 @pytest.mark.anyio
3479 async def test_session_list_page_contains_live_indicator_markup(
3480 client: AsyncClient,
3481 db_session: AsyncSession,
3482 ) -> None:
3483 """Sessions list page HTML contains pulsing LIVE indicator JS reference."""
3484 repo_id = await _make_repo(db_session)
3485 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3486 assert response.status_code == 200
3487 body = response.text
3488 assert "session-live-pulse" in body
3489
3490
3491 @pytest.mark.anyio
3492 async def test_session_list_page_contains_notes_preview_markup(
3493 client: AsyncClient,
3494 db_session: AsyncSession,
3495 ) -> None:
3496 """Sessions list page HTML contains notes preview JS reference."""
3497 repo_id = await _make_repo(db_session)
3498 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3499 assert response.status_code == 200
3500 body = response.text
3501 assert "session-notes-preview" in body
3502 assert "notesPreview" in body
3503
3504
3505 @pytest.mark.anyio
3506 async def test_session_list_page_contains_location_tag_markup(
3507 client: AsyncClient,
3508 db_session: AsyncSession,
3509 ) -> None:
3510 """Sessions list page HTML contains location tag JS reference."""
3511 repo_id = await _make_repo(db_session)
3512 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
3513 assert response.status_code == 200
3514 body = response.text
3515 assert "session-location-tag" in body
3516
3517
3518 async def test_contour_page_renders(
3519 client: AsyncClient,
3520 db_session: AsyncSession,
3521 ) -> None:
3522 """GET /musehub/ui/{repo_id}/analysis/{ref}/contour returns 200 HTML."""
3523 repo_id = await _make_repo(db_session)
3524 ref = "abc1234567890abcdef"
3525 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/contour")
3526 assert response.status_code == 200
3527 assert "text/html" in response.headers["content-type"]
3528
3529
3530 @pytest.mark.anyio
3531 async def test_contour_page_no_auth_required(
3532 client: AsyncClient,
3533 db_session: AsyncSession,
3534 ) -> None:
3535 """Contour analysis page must be accessible without a JWT (HTML shell handles auth)."""
3536 repo_id = await _make_repo(db_session)
3537 ref = "deadbeef1234"
3538 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/contour")
3539 assert response.status_code != 401
3540 assert response.status_code == 200
3541
3542
3543 @pytest.mark.anyio
3544 async def test_contour_page_contains_graph_ui(
3545 client: AsyncClient,
3546 db_session: AsyncSession,
3547 ) -> None:
3548 """Contour page SSR: must contain pitch-curve polyline, shape summary, and direction data."""
3549 repo_id = await _make_repo(db_session)
3550 ref = "cafebabe12345678"
3551 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/contour")
3552 assert response.status_code == 200
3553 body = response.text
3554 assert "Melodic Contour" in body
3555 assert "<polyline" in body or "PITCH CURVE" in body
3556 assert "Shape" in body
3557 assert "Overall Direction" in body
3558 assert repo_id in body
3559
3560
3561 @pytest.mark.anyio
3562 async def test_contour_json_response(
3563 client: AsyncClient,
3564 auth_headers: dict[str, str],
3565 db_session: AsyncSession,
3566 ) -> None:
3567 """GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/contour returns ContourData.
3568
3569 Verifies that the JSON response includes shape classification labels and
3570 the pitch_curve array that the contour page visualises.
3571 """
3572 resp = await client.post(
3573 "/api/v1/musehub/repos",
3574 json={"name": "contour-test-repo", "owner": "testuser", "visibility": "private"},
3575 headers=auth_headers,
3576 )
3577 assert resp.status_code == 201
3578 repo_id = resp.json()["repoId"]
3579
3580 resp = await client.get(
3581 f"/api/v1/musehub/repos/{repo_id}/analysis/main/contour",
3582 headers=auth_headers,
3583 )
3584 assert resp.status_code == 200
3585 body = resp.json()
3586 assert body["dimension"] == "contour"
3587 assert body["ref"] == "main"
3588 data = body["data"]
3589 assert "shape" in data
3590 assert "pitchCurve" in data
3591 assert "overallDirection" in data
3592 assert "directionChanges" in data
3593 assert len(data["pitchCurve"]) > 0
3594 assert data["shape"] in ("arch", "ascending", "descending", "flat", "wave")
3595
3596
3597 @pytest.mark.anyio
3598 async def test_tempo_page_renders(
3599 client: AsyncClient,
3600 db_session: AsyncSession,
3601 ) -> None:
3602 """GET /musehub/ui/{repo_id}/analysis/{ref}/tempo returns 200 HTML."""
3603 repo_id = await _make_repo(db_session)
3604 ref = "abc1234567890abcdef"
3605 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/tempo")
3606 assert response.status_code == 200
3607 assert "text/html" in response.headers["content-type"]
3608
3609
3610 @pytest.mark.anyio
3611 async def test_tempo_page_no_auth_required(
3612 client: AsyncClient,
3613 db_session: AsyncSession,
3614 ) -> None:
3615 """Tempo analysis page must be accessible without a JWT (HTML shell handles auth)."""
3616 repo_id = await _make_repo(db_session)
3617 ref = "deadbeef5678"
3618 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/tempo")
3619 assert response.status_code != 401
3620 assert response.status_code == 200
3621
3622
3623 @pytest.mark.anyio
3624 async def test_tempo_page_contains_bpm_ui(
3625 client: AsyncClient,
3626 db_session: AsyncSession,
3627 ) -> None:
3628 """Tempo page must contain BPM display, stability bar, and tempo-change timeline."""
3629 repo_id = await _make_repo(db_session)
3630 ref = "feedface5678"
3631 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/tempo")
3632 assert response.status_code == 200
3633 body = response.text
3634 assert "Tempo Analysis" in body
3635 assert "BPM" in body
3636 assert "Stability" in body
3637 assert "tempoChangeSvg" in body or "tempoChanges" in body or "Tempo Changes" in body
3638 assert repo_id in body
3639
3640
3641 @pytest.mark.anyio
3642 async def test_tempo_json_response(
3643 client: AsyncClient,
3644 auth_headers: dict[str, str],
3645 db_session: AsyncSession,
3646 ) -> None:
3647 """GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/tempo returns TempoData.
3648
3649 Verifies that the JSON response includes BPM, stability, time feel, and
3650 tempo_changes history that the tempo page visualises.
3651 """
3652 resp = await client.post(
3653 "/api/v1/musehub/repos",
3654 json={"name": "tempo-test-repo", "owner": "testuser", "visibility": "private"},
3655 headers=auth_headers,
3656 )
3657 assert resp.status_code == 201
3658 repo_id = resp.json()["repoId"]
3659
3660 resp = await client.get(
3661 f"/api/v1/musehub/repos/{repo_id}/analysis/main/tempo",
3662 headers=auth_headers,
3663 )
3664 assert resp.status_code == 200
3665 body = resp.json()
3666 assert body["dimension"] == "tempo"
3667 assert body["ref"] == "main"
3668 data = body["data"]
3669 assert "bpm" in data
3670 assert "stability" in data
3671 assert "timeFeel" in data
3672 assert "tempoChanges" in data
3673 assert data["bpm"] > 0
3674 assert 0.0 <= data["stability"] <= 1.0
3675 assert isinstance(data["tempoChanges"], list)
3676
3677
3678 # ---------------------------------------------------------------------------
3679 # Form and structure page tests
3680 # ---------------------------------------------------------------------------
3681
3682
3683 @pytest.mark.anyio
3684 async def test_form_structure_page_renders(
3685 client: AsyncClient,
3686 db_session: AsyncSession,
3687 ) -> None:
3688 """GET /musehub/ui/{repo_id}/form-structure/{ref} returns 200 HTML without auth."""
3689 repo_id = await _make_repo(db_session)
3690 ref = "abc1234567890abcdef"
3691 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3692 assert response.status_code == 200
3693 assert "text/html" in response.headers["content-type"]
3694 body = response.text
3695 assert "Muse Hub" in body
3696 assert "Form" in body
3697
3698
3699 @pytest.mark.anyio
3700 async def test_form_structure_page_no_auth_required(
3701 client: AsyncClient,
3702 db_session: AsyncSession,
3703 ) -> None:
3704 """Form-structure UI page must be accessible without an Authorization header."""
3705 repo_id = await _make_repo(db_session)
3706 ref = "deadbeef1234"
3707 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3708 assert response.status_code != 401
3709 assert response.status_code == 200
3710
3711
3712 @pytest.mark.anyio
3713 async def test_form_structure_page_contains_section_map(
3714 client: AsyncClient,
3715 db_session: AsyncSession,
3716 ) -> None:
3717 """Form-structure page embeds section map SVG rendering logic."""
3718 repo_id = await _make_repo(db_session)
3719 ref = "cafebabe1234"
3720 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3721 assert response.status_code == 200
3722 body = response.text
3723 assert "Section Map" in body
3724 assert "renderSectionMap" in body
3725 assert "sectionMap" in body
3726
3727
3728 @pytest.mark.anyio
3729 async def test_form_structure_page_contains_repetition_panel(
3730 client: AsyncClient,
3731 db_session: AsyncSession,
3732 ) -> None:
3733 """Form-structure page embeds repetition structure panel."""
3734 repo_id = await _make_repo(db_session)
3735 ref = "feedface0123"
3736 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3737 assert response.status_code == 200
3738 body = response.text
3739 assert "Repetition" in body
3740 assert "renderRepetition" in body
3741
3742
3743 @pytest.mark.anyio
3744 async def test_form_structure_page_contains_heatmap(
3745 client: AsyncClient,
3746 db_session: AsyncSession,
3747 ) -> None:
3748 """Form-structure page embeds section comparison heatmap renderer."""
3749 repo_id = await _make_repo(db_session)
3750 ref = "deadcafe5678"
3751 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3752 assert response.status_code == 200
3753 body = response.text
3754 assert "Section Comparison" in body
3755 assert "renderHeatmap" in body
3756 assert "sectionComparison" in body
3757
3758
3759 @pytest.mark.anyio
3760 async def test_form_structure_page_includes_token_form(
3761 client: AsyncClient,
3762 db_session: AsyncSession,
3763 ) -> None:
3764 """Form-structure page includes the JWT token form and musehub.js auth infrastructure."""
3765 repo_id = await _make_repo(db_session)
3766 ref = "babe1234abcd"
3767 response = await client.get(f"/musehub/ui/{repo_id}/form-structure/{ref}")
3768 assert response.status_code == 200
3769 body = response.text
3770 assert "musehub.js" in body
3771 assert "token-form" in body
3772
3773
3774 @pytest.mark.anyio
3775 async def test_form_structure_json_response(
3776 client: AsyncClient,
3777 db_session: AsyncSession,
3778 auth_headers: dict[str, str],
3779 ) -> None:
3780 """GET /api/v1/musehub/repos/{repo_id}/form-structure/{ref} returns JSON with required fields."""
3781 repo_id = await _make_repo(db_session)
3782 ref = "abc1234567890abcdef"
3783 response = await client.get(
3784 f"/api/v1/musehub/repos/{repo_id}/form-structure/{ref}",
3785 headers=auth_headers,
3786 )
3787 assert response.status_code == 200
3788 body = response.json()
3789 assert "repoId" in body
3790 assert "ref" in body
3791 assert "formLabel" in body
3792 assert "timeSignature" in body
3793 assert "beatsPerBar" in body
3794 assert "totalBars" in body
3795 assert "sectionMap" in body
3796 assert "repetitionStructure" in body
3797 assert "sectionComparison" in body
3798 assert body["repoId"] == repo_id
3799 assert body["ref"] == ref
3800
3801
3802 @pytest.mark.anyio
3803 async def test_form_structure_json_section_map_fields(
3804 client: AsyncClient,
3805 db_session: AsyncSession,
3806 auth_headers: dict[str, str],
3807 ) -> None:
3808 """Each sectionMap entry has label, startBar, endBar, barCount, and colorHint."""
3809 repo_id = await _make_repo(db_session)
3810 ref = "abc1234567890abcdef"
3811 response = await client.get(
3812 f"/api/v1/musehub/repos/{repo_id}/form-structure/{ref}",
3813 headers=auth_headers,
3814 )
3815 assert response.status_code == 200
3816 body = response.json()
3817 sections = body["sectionMap"]
3818 assert len(sections) > 0
3819 for sec in sections:
3820 assert "label" in sec
3821 assert "function" in sec
3822 assert "startBar" in sec
3823 assert "endBar" in sec
3824 assert "barCount" in sec
3825 assert "colorHint" in sec
3826 assert sec["startBar"] >= 1
3827 assert sec["endBar"] >= sec["startBar"]
3828 assert sec["barCount"] >= 1
3829
3830
3831 @pytest.mark.anyio
3832 async def test_form_structure_json_heatmap_is_symmetric(
3833 client: AsyncClient,
3834 db_session: AsyncSession,
3835 auth_headers: dict[str, str],
3836 ) -> None:
3837 """Section comparison heatmap matrix must be square and symmetric with diagonal 1.0."""
3838 repo_id = await _make_repo(db_session)
3839 ref = "abc1234567890abcdef"
3840 response = await client.get(
3841 f"/api/v1/musehub/repos/{repo_id}/form-structure/{ref}",
3842 headers=auth_headers,
3843 )
3844 assert response.status_code == 200
3845 body = response.json()
3846 heatmap = body["sectionComparison"]
3847 labels = heatmap["labels"]
3848 matrix = heatmap["matrix"]
3849 n = len(labels)
3850 assert len(matrix) == n
3851 for i in range(n):
3852 assert len(matrix[i]) == n
3853 assert matrix[i][i] == 1.0
3854 for i in range(n):
3855 for j in range(n):
3856 assert 0.0 <= matrix[i][j] <= 1.0
3857
3858
3859 @pytest.mark.anyio
3860 async def test_form_structure_json_404_unknown_repo(
3861 client: AsyncClient,
3862 db_session: AsyncSession,
3863 auth_headers: dict[str, str],
3864 ) -> None:
3865 """GET /api/v1/musehub/repos/{unknown}/form-structure/{ref} returns 404."""
3866 response = await client.get(
3867 "/api/v1/musehub/repos/does-not-exist/form-structure/abc123",
3868 headers=auth_headers,
3869 )
3870 assert response.status_code == 404
3871
3872
3873 @pytest.mark.anyio
3874 async def test_form_structure_json_requires_auth(
3875 client: AsyncClient,
3876 db_session: AsyncSession,
3877 ) -> None:
3878 """GET /api/v1/musehub/repos/{repo_id}/form-structure/{ref} returns 401 without auth."""
3879 repo_id = await _make_repo(db_session)
3880 response = await client.get(
3881 f"/api/v1/musehub/repos/{repo_id}/form-structure/abc123",
3882 )
3883 assert response.status_code == 401
3884
3885
3886 # ---------------------------------------------------------------------------
3887 # Emotion map page tests (migrated to owner/slug routing)
3888 # ---------------------------------------------------------------------------
3889
3890 _EMOTION_REF = "deadbeef12345678"
3891
3892
3893 @pytest.mark.anyio
3894 async def test_emotion_page_renders(
3895 client: AsyncClient,
3896 db_session: AsyncSession,
3897 ) -> None:
3898 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/emotion returns 200 HTML without auth."""
3899 await _make_repo(db_session)
3900 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
3901 assert response.status_code == 200
3902 assert "text/html" in response.headers["content-type"]
3903 body = response.text
3904 assert "Muse Hub" in body
3905 assert "Emotion" in body
3906
3907
3908 @pytest.mark.anyio
3909 async def test_emotion_page_no_auth_required(
3910 client: AsyncClient,
3911 db_session: AsyncSession,
3912 ) -> None:
3913 """Emotion UI page must be accessible without an Authorization header (HTML shell)."""
3914 await _make_repo(db_session)
3915 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
3916 assert response.status_code != 401
3917 assert response.status_code == 200
3918
3919
3920 @pytest.mark.anyio
3921 async def test_emotion_page_includes_charts(
3922 client: AsyncClient,
3923 db_session: AsyncSession,
3924 ) -> None:
3925 """Emotion page SSR: must contain SVG scatter plot and axis dimension labels."""
3926 await _make_repo(db_session)
3927 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
3928 assert response.status_code == 200
3929 body = response.text
3930 assert "<circle" in body or "<svg" in body
3931 assert "Valence" in body
3932 assert "Tension" in body
3933 assert "Energy" in body
3934
3935
3936 @pytest.mark.anyio
3937 async def test_emotion_page_includes_filters(
3938 client: AsyncClient,
3939 db_session: AsyncSession,
3940 ) -> None:
3941 """Emotion page SSR: must contain summary vector bars and trajectory section."""
3942 await _make_repo(db_session)
3943 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{_EMOTION_REF}/emotion")
3944 assert response.status_code == 200
3945 body = response.text
3946 assert "SUMMARY VECTOR" in body
3947 assert "TRAJECTORY" in body
3948
3949
3950 @pytest.mark.anyio
3951 async def test_emotion_json_response(
3952 client: AsyncClient,
3953 db_session: AsyncSession,
3954 auth_headers: dict[str, str],
3955 ) -> None:
3956 """GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/emotion-map returns required fields."""
3957 repo_id = await _make_repo(db_session)
3958 response = await client.get(
3959 f"/api/v1/musehub/repos/{repo_id}/analysis/{_EMOTION_REF}/emotion-map",
3960 headers=auth_headers,
3961 )
3962 assert response.status_code == 200
3963 body = response.json()
3964 assert body["repoId"] == repo_id
3965 assert body["ref"] == _EMOTION_REF
3966 assert "computedAt" in body
3967 assert "summaryVector" in body
3968 sv = body["summaryVector"]
3969 for axis in ("energy", "valence", "tension", "darkness"):
3970 assert axis in sv
3971 assert 0.0 <= sv[axis] <= 1.0
3972 assert "evolution" in body
3973 assert isinstance(body["evolution"], list)
3974 assert len(body["evolution"]) > 0
3975 assert "narrative" in body
3976 assert len(body["narrative"]) > 0
3977 assert "source" in body
3978
3979
3980 @pytest.mark.anyio
3981 async def test_emotion_trajectory(
3982 client: AsyncClient,
3983 db_session: AsyncSession,
3984 auth_headers: dict[str, str],
3985 ) -> None:
3986 """Cross-commit trajectory must be a list of commit snapshots with emotion vectors."""
3987 repo_id = await _make_repo(db_session)
3988 response = await client.get(
3989 f"/api/v1/musehub/repos/{repo_id}/analysis/{_EMOTION_REF}/emotion-map",
3990 headers=auth_headers,
3991 )
3992 assert response.status_code == 200
3993 trajectory = response.json()["trajectory"]
3994 assert isinstance(trajectory, list)
3995 assert len(trajectory) >= 2
3996 for snapshot in trajectory:
3997 assert "commitId" in snapshot
3998 assert "message" in snapshot
3999 assert "primaryEmotion" in snapshot
4000 vector = snapshot["vector"]
4001 for axis in ("energy", "valence", "tension", "darkness"):
4002 assert axis in vector
4003 assert 0.0 <= vector[axis] <= 1.0
4004
4005
4006 @pytest.mark.anyio
4007 async def test_emotion_drift_distances(
4008 client: AsyncClient,
4009 db_session: AsyncSession,
4010 auth_headers: dict[str, str],
4011 ) -> None:
4012 """Drift list must have exactly len(trajectory) - 1 entries."""
4013 repo_id = await _make_repo(db_session)
4014 response = await client.get(
4015 f"/api/v1/musehub/repos/{repo_id}/analysis/{_EMOTION_REF}/emotion-map",
4016 headers=auth_headers,
4017 )
4018 assert response.status_code == 200
4019 body = response.json()
4020 trajectory = body["trajectory"]
4021 drift = body["drift"]
4022 assert isinstance(drift, list)
4023 assert len(drift) == len(trajectory) - 1
4024 for entry in drift:
4025 assert "fromCommit" in entry
4026 assert "toCommit" in entry
4027 assert "drift" in entry
4028 assert entry["drift"] >= 0.0
4029 assert "dominantChange" in entry
4030 assert entry["dominantChange"] in ("energy", "valence", "tension", "darkness")
4031
4032
4033 # ---------------------------------------------------------------------------
4034 # owner/slug navigation link correctness (regression for PR #282)
4035 # ---------------------------------------------------------------------------
4036
4037
4038 @pytest.mark.anyio
4039 async def test_ui_nav_links_use_owner_slug_not_uuid_repo_page(
4040 client: AsyncClient,
4041 db_session: AsyncSession,
4042 ) -> None:
4043 """Repo page must inject owner/slug base URL, not the internal UUID.
4044
4045 Before the fix, every handler except repo_page used ``const base =
4046 '/musehub/ui/' + repoId``. That produced UUID-based hrefs that 404 under
4047 the new /{owner}/{repo_slug} routing. This test guards the regression.
4048 """
4049 await _make_repo(db_session)
4050 response = await client.get("/musehub/ui/testuser/test-beats")
4051 assert response.status_code == 200
4052 body = response.text
4053 # JS base variable must use owner/slug, not UUID concatenation
4054 assert '"/musehub/ui/testuser/test-beats"' in body
4055 # UUID-concatenation pattern must NOT appear
4056 assert "'/musehub/ui/' + repoId" not in body
4057
4058
4059 @pytest.mark.anyio
4060 async def test_ui_nav_links_use_owner_slug_not_uuid_commit_page(
4061 client: AsyncClient,
4062 db_session: AsyncSession,
4063 ) -> None:
4064 """Commit page back-to-repo link must use owner/slug, not internal UUID."""
4065 from datetime import datetime, timezone
4066
4067 repo_id = await _make_repo(db_session)
4068 commit_id = "abc1234567890123456789012345678901234567"
4069 commit = MusehubCommit(
4070 commit_id=commit_id,
4071 repo_id=repo_id,
4072 branch="main",
4073 parent_ids=[],
4074 message="Test commit",
4075 author="testuser",
4076 timestamp=datetime.now(tz=timezone.utc),
4077 )
4078 db_session.add(commit)
4079 await db_session.commit()
4080
4081 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
4082 assert response.status_code == 200
4083 body = response.text
4084 assert "/musehub/ui/testuser/test-beats" in body
4085 assert "'/musehub/ui/' + repoId" not in body
4086
4087
4088 @pytest.mark.anyio
4089 async def test_ui_nav_links_use_owner_slug_not_uuid_graph_page(
4090 client: AsyncClient,
4091 db_session: AsyncSession,
4092 ) -> None:
4093 """Graph page back-to-repo link must use owner/slug, not internal UUID."""
4094 await _make_repo(db_session)
4095 response = await client.get("/musehub/ui/testuser/test-beats/graph")
4096 assert response.status_code == 200
4097 body = response.text
4098 assert '"/musehub/ui/testuser/test-beats"' in body
4099 assert "'/musehub/ui/' + repoId" not in body
4100
4101
4102 @pytest.mark.anyio
4103 async def test_ui_nav_links_use_owner_slug_not_uuid_pr_list_page(
4104 client: AsyncClient,
4105 db_session: AsyncSession,
4106 ) -> None:
4107 """PR list page navigation must use owner/slug, not internal UUID."""
4108 await _make_repo(db_session)
4109 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
4110 assert response.status_code == 200
4111 body = response.text
4112 assert '"/musehub/ui/testuser/test-beats"' in body
4113 assert "'/musehub/ui/' + repoId" not in body
4114
4115
4116 @pytest.mark.anyio
4117 async def test_ui_nav_links_use_owner_slug_not_uuid_releases_page(
4118 client: AsyncClient,
4119 db_session: AsyncSession,
4120 ) -> None:
4121 """Releases page navigation must use owner/slug, not internal UUID."""
4122 await _make_repo(db_session)
4123 response = await client.get("/musehub/ui/testuser/test-beats/releases")
4124 assert response.status_code == 200
4125 body = response.text
4126 assert '"/musehub/ui/testuser/test-beats"' in body
4127 assert "'/musehub/ui/' + repoId" not in body
4128
4129
4130 @pytest.mark.anyio
4131 async def test_ui_unknown_owner_slug_returns_404(
4132 client: AsyncClient,
4133 db_session: AsyncSession,
4134 ) -> None:
4135 """GET /musehub/ui/{unknown-owner}/{unknown-slug} must return 404."""
4136 response = await client.get("/musehub/ui/nobody/nonexistent-repo")
4137 assert response.status_code == 404
4138
4139
4140 # ---------------------------------------------------------------------------
4141 # Issue #199 — Design System Tests
4142 # ---------------------------------------------------------------------------
4143
4144
4145 @pytest.mark.anyio
4146 async def test_design_tokens_css_served(client: AsyncClient) -> None:
4147 """GET /musehub/static/tokens.css must return 200 with CSS content-type.
4148
4149 Verifies the design token file is reachable at its canonical static path.
4150 If this fails, every MuseHub page will render unstyled because the CSS
4151 custom properties (--bg-base, --color-accent, etc.) will be missing.
4152 """
4153 response = await client.get("/musehub/static/tokens.css")
4154 assert response.status_code == 200
4155 assert "text/css" in response.headers.get("content-type", "")
4156 body = response.text
4157 assert "--bg-base" in body
4158 assert "--color-accent" in body
4159 assert "--dim-harmonic" in body
4160
4161
4162 @pytest.mark.anyio
4163 async def test_components_css_served(client: AsyncClient) -> None:
4164 """GET /musehub/static/components.css must return 200 with CSS content.
4165
4166 Verifies the component class file is reachable. These classes (.card,
4167 .badge, .btn, etc.) are used on every MuseHub page.
4168 """
4169 response = await client.get("/musehub/static/components.css")
4170 assert response.status_code == 200
4171 assert "text/css" in response.headers.get("content-type", "")
4172 body = response.text
4173 assert ".badge" in body
4174 assert ".btn" in body
4175 assert ".card" in body
4176
4177
4178 @pytest.mark.anyio
4179 async def test_layout_css_served(client: AsyncClient) -> None:
4180 """GET /musehub/static/layout.css must return 200."""
4181 response = await client.get("/musehub/static/layout.css")
4182 assert response.status_code == 200
4183 assert "text/css" in response.headers.get("content-type", "")
4184 assert ".container" in response.text
4185
4186
4187 @pytest.mark.anyio
4188 async def test_icons_css_served(client: AsyncClient) -> None:
4189 """GET /musehub/static/icons.css must return 200."""
4190 response = await client.get("/musehub/static/icons.css")
4191 assert response.status_code == 200
4192 assert "text/css" in response.headers.get("content-type", "")
4193 assert ".icon-mid" in response.text
4194
4195
4196 @pytest.mark.anyio
4197 async def test_music_css_served(client: AsyncClient) -> None:
4198 """GET /musehub/static/music.css must return 200."""
4199 response = await client.get("/musehub/static/music.css")
4200 assert response.status_code == 200
4201 assert "text/css" in response.headers.get("content-type", "")
4202 assert ".piano-roll" in response.text
4203
4204
4205 @pytest.mark.anyio
4206 async def test_repo_page_uses_design_system(
4207 client: AsyncClient,
4208 db_session: AsyncSession,
4209 ) -> None:
4210 """Repo page HTML must reference all five design system CSS files.
4211
4212 This is the regression guard for the monolithic _CSS removal. If the
4213 _page() helper ever reverts to embedding CSS inline, this test will
4214 catch it by asserting the external link tags are present.
4215 """
4216 await _make_repo(db_session)
4217 response = await client.get("/musehub/ui/testuser/test-beats")
4218 assert response.status_code == 200
4219 body = response.text
4220 assert "/musehub/static/tokens.css" in body
4221 assert "/musehub/static/components.css" in body
4222 assert "/musehub/static/layout.css" in body
4223 assert "/musehub/static/icons.css" in body
4224 assert "/musehub/static/music.css" in body
4225
4226
4227 @pytest.mark.anyio
4228 async def test_responsive_meta_tag_present_repo_page(
4229 client: AsyncClient,
4230 db_session: AsyncSession,
4231 ) -> None:
4232 """Repo page must include a viewport meta tag for mobile responsiveness."""
4233 await _make_repo(db_session)
4234 response = await client.get("/musehub/ui/testuser/test-beats")
4235 assert response.status_code == 200
4236 assert 'name="viewport"' in response.text
4237
4238
4239 @pytest.mark.anyio
4240 async def test_responsive_meta_tag_present_pr_page(
4241 client: AsyncClient,
4242 db_session: AsyncSession,
4243 ) -> None:
4244 """PR list page must include a viewport meta tag for mobile responsiveness."""
4245 await _make_repo(db_session)
4246 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
4247 assert response.status_code == 200
4248 assert 'name="viewport"' in response.text
4249
4250
4251 @pytest.mark.anyio
4252 async def test_responsive_meta_tag_present_issues_page(
4253 client: AsyncClient,
4254 db_session: AsyncSession,
4255 ) -> None:
4256 """Issues page must include a viewport meta tag for mobile responsiveness."""
4257 await _make_repo(db_session)
4258 response = await client.get("/musehub/ui/testuser/test-beats/issues")
4259 assert response.status_code == 200
4260 assert 'name="viewport"' in response.text
4261
4262
4263 @pytest.mark.anyio
4264 async def test_design_tokens_css_contains_dimension_colors(
4265 client: AsyncClient,
4266 ) -> None:
4267 """tokens.css must define all five musical dimension color tokens.
4268
4269 These tokens are used in piano rolls, radar charts, and diff heatmaps.
4270 Missing tokens would break analysis page visualisations silently.
4271 """
4272 response = await client.get("/musehub/static/tokens.css")
4273 assert response.status_code == 200
4274 body = response.text
4275 for dim in ("harmonic", "rhythmic", "melodic", "structural", "dynamic"):
4276 assert f"--dim-{dim}:" in body, f"Missing dimension token --dim-{dim}"
4277
4278
4279 @pytest.mark.anyio
4280 async def test_design_tokens_css_contains_track_colors(
4281 client: AsyncClient,
4282 ) -> None:
4283 """tokens.css must define all 8 track color tokens (--track-0 through --track-7)."""
4284 response = await client.get("/musehub/static/tokens.css")
4285 assert response.status_code == 200
4286 body = response.text
4287 for i in range(8):
4288 assert f"--track-{i}:" in body, f"Missing track color token --track-{i}"
4289
4290
4291 @pytest.mark.anyio
4292 async def test_badge_variants_in_components_css(client: AsyncClient) -> None:
4293 """components.css must define all required badge variants including .badge-clean and .badge-dirty."""
4294 response = await client.get("/musehub/static/components.css")
4295 assert response.status_code == 200
4296 body = response.text
4297 for variant in ("open", "closed", "merged", "active", "clean", "dirty"):
4298 assert f".badge-{variant}" in body, f"Missing badge variant .badge-{variant}"
4299
4300
4301 @pytest.mark.anyio
4302 async def test_file_type_icons_in_icons_css(client: AsyncClient) -> None:
4303 """icons.css must define icon classes for all required file types."""
4304 response = await client.get("/musehub/static/icons.css")
4305 assert response.status_code == 200
4306 body = response.text
4307 for ext in ("mid", "mp3", "wav", "json", "webp", "xml", "abc"):
4308 assert f".icon-{ext}" in body, f"Missing file-type icon .icon-{ext}"
4309
4310
4311 @pytest.mark.anyio
4312 async def test_no_inline_css_on_repo_page(
4313 client: AsyncClient,
4314 db_session: AsyncSession,
4315 ) -> None:
4316 """Repo page must NOT embed the old monolithic CSS string inline.
4317
4318 Regression test: verifies the _CSS removal was not accidentally reverted.
4319 The old _CSS block contained the literal string 'background: #0d1117'
4320 inside a <style> tag in the <head>. After the design system migration,
4321 all styling comes from external files.
4322 """
4323 await _make_repo(db_session)
4324 response = await client.get("/musehub/ui/testuser/test-beats")
4325 body = response.text
4326 # Find the <head> section — inline CSS should not appear there
4327 head_end = body.find("</head>")
4328 head_section = body[:head_end] if head_end != -1 else body
4329 # The old monolithic block started with "box-sizing: border-box"
4330 # If it appears inside <head>, the migration has been reverted.
4331 assert "box-sizing: border-box; margin: 0; padding: 0;" not in head_section
4332
4333
4334 # ---------------------------------------------------------------------------
4335 # Analysis dashboard UI tests
4336 # ---------------------------------------------------------------------------
4337
4338
4339 @pytest.mark.anyio
4340 async def test_analysis_dashboard_renders(
4341 client: AsyncClient,
4342 db_session: AsyncSession,
4343 ) -> None:
4344 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref} returns 200 HTML without a JWT."""
4345 await _make_repo(db_session)
4346 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4347 assert response.status_code == 200
4348 assert "text/html" in response.headers["content-type"]
4349 body = response.text
4350 assert "Muse Hub" in body
4351 assert "Analysis" in body
4352 assert "test-beats" in body
4353
4354
4355 @pytest.mark.anyio
4356 async def test_analysis_dashboard_no_auth_required(
4357 client: AsyncClient,
4358 db_session: AsyncSession,
4359 ) -> None:
4360 """Analysis dashboard HTML shell must be accessible without an Authorization header."""
4361 await _make_repo(db_session)
4362 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4363 assert response.status_code == 200
4364 assert response.status_code != 401
4365
4366
4367 @pytest.mark.anyio
4368 async def test_analysis_dashboard_all_dimension_labels(
4369 client: AsyncClient,
4370 db_session: AsyncSession,
4371 ) -> None:
4372 """Dashboard HTML embeds all 10 required dimension card labels in the page script.
4373
4374 Regression test: if any card label is missing the JS template
4375 will silently skip rendering that dimension, so agents get an incomplete picture.
4376 """
4377 await _make_repo(db_session)
4378 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4379 assert response.status_code == 200
4380 body = response.text
4381 for label in ("Key", "Tempo", "Meter", "Chord Map", "Dynamics",
4382 "Groove", "Emotion", "Form", "Motifs", "Contour"):
4383 assert label in body, f"Expected dimension label {label!r} in dashboard HTML"
4384
4385
4386 @pytest.mark.anyio
4387 async def test_analysis_dashboard_sparkline_logic_present(
4388 client: AsyncClient,
4389 db_session: AsyncSession,
4390 ) -> None:
4391 """Dashboard renders dimension cards server-side with key musical data visible in HTML.
4392
4393 Updated for SSR migration (issue #578): the dashboard now renders all dimension
4394 data via Jinja2 rather than fetching via client-side JS. Key/tempo/meter/groove/form
4395 data is embedded directly in the HTML — no JS sparkline or API fetch is needed.
4396 """
4397 await _make_repo(db_session)
4398 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4399 assert response.status_code == 200
4400 body = response.text
4401 # SSR dashboard renders dimension cards with inline data (tonic, BPM, time-sig, etc.)
4402 assert "Key" in body
4403 assert "Tempo" in body
4404 assert "/analysis/" in body
4405
4406
4407 @pytest.mark.anyio
4408 async def test_analysis_dashboard_card_links_to_dimensions(
4409 client: AsyncClient,
4410 db_session: AsyncSession,
4411 ) -> None:
4412 """Each dimension card must link to the per-dimension analysis detail page.
4413
4414 The card href is built client-side from ``base + '/analysis/' + ref + '/' + id``,
4415 so the JS template string must reference ``/analysis/`` as the path segment.
4416 """
4417 await _make_repo(db_session)
4418 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main")
4419 assert response.status_code == 200
4420 body = response.text
4421 assert "/analysis/" in body
4422
4423
4424
4425
4426 # ---------------------------------------------------------------------------
4427 # Motifs browser page — # ---------------------------------------------------------------------------
4428
4429
4430 @pytest.mark.anyio
4431 async def test_motifs_page_renders(
4432 client: AsyncClient,
4433 db_session: AsyncSession,
4434 ) -> None:
4435 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/motifs returns 200 HTML."""
4436 repo_id = await _make_repo(db_session)
4437 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4438 assert response.status_code == 200
4439 assert "text/html" in response.headers["content-type"]
4440 body = response.text
4441 assert "Muse Hub" in body
4442
4443
4444 @pytest.mark.anyio
4445 async def test_motifs_page_no_auth_required(
4446 client: AsyncClient,
4447 db_session: AsyncSession,
4448 ) -> None:
4449 """Motifs UI page must be accessible without an Authorization header."""
4450 repo_id = await _make_repo(db_session)
4451 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4452 assert response.status_code == 200
4453 assert response.status_code != 401
4454
4455
4456 @pytest.mark.anyio
4457 async def test_motifs_page_contains_filter_ui(
4458 client: AsyncClient,
4459 db_session: AsyncSession,
4460 ) -> None:
4461 """Motifs page SSR: must contain interval pattern section and occurrence markers."""
4462 repo_id = await _make_repo(db_session)
4463 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4464 assert response.status_code == 200
4465 body = response.text
4466 assert "INTERVAL PATTERN" in body
4467 assert "OCCURRENCES" in body
4468
4469
4470 @pytest.mark.anyio
4471 async def test_motifs_page_contains_piano_roll_renderer(
4472 client: AsyncClient,
4473 db_session: AsyncSession,
4474 ) -> None:
4475 """Motifs page SSR: must contain motif browser heading and interval data."""
4476 repo_id = await _make_repo(db_session)
4477 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4478 assert response.status_code == 200
4479 body = response.text
4480 assert "Motif Browser" in body
4481 assert "INTERVAL PATTERN" in body
4482
4483
4484 @pytest.mark.anyio
4485 async def test_motifs_page_contains_recurrence_grid(
4486 client: AsyncClient,
4487 db_session: AsyncSession,
4488 ) -> None:
4489 """Motifs page SSR: must contain recurrence grid section rendered server-side."""
4490 repo_id = await _make_repo(db_session)
4491 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4492 assert response.status_code == 200
4493 body = response.text
4494 assert "RECURRENCE GRID" in body or "occurrence" in body.lower()
4495
4496
4497 @pytest.mark.anyio
4498 async def test_motifs_page_shows_transformation_badges(
4499 client: AsyncClient,
4500 db_session: AsyncSession,
4501 ) -> None:
4502 """Motifs page SSR: must contain TRANSFORMATIONS section with inversion type labels."""
4503 repo_id = await _make_repo(db_session)
4504 response = await client.get("/musehub/ui/testuser/test-beats/analysis/main/motifs")
4505 assert response.status_code == 200
4506 body = response.text
4507 assert "TRANSFORMATIONS" in body
4508 assert "inversion" in body
4509
4510
4511 # ---------------------------------------------------------------------------
4512 # Content negotiation & repo home page tests — / #203
4513 # ---------------------------------------------------------------------------
4514
4515
4516 @pytest.mark.anyio
4517 async def test_repo_page_html_default(
4518 client: AsyncClient,
4519 db_session: AsyncSession,
4520 ) -> None:
4521 """GET /musehub/ui/{owner}/{repo_slug} with no Accept header returns HTML by default."""
4522 await _make_repo(db_session)
4523 response = await client.get("/musehub/ui/testuser/test-beats")
4524 assert response.status_code == 200
4525 assert "text/html" in response.headers["content-type"]
4526 body = response.text
4527 assert "Muse Hub" in body
4528 assert "testuser" in body
4529 assert "test-beats" in body
4530
4531
4532 @pytest.mark.anyio
4533 async def test_repo_home_shows_stats(
4534 client: AsyncClient,
4535 db_session: AsyncSession,
4536 ) -> None:
4537 """Repo home page embeds JS that fetches and renders the stats bar."""
4538 await _make_repo(db_session)
4539 response = await client.get("/musehub/ui/testuser/test-beats")
4540 assert response.status_code == 200
4541 body = response.text
4542 assert "stats-bar" in body
4543 assert "loadStats" in body
4544
4545
4546 @pytest.mark.anyio
4547 async def test_repo_home_recent_commits(
4548 client: AsyncClient,
4549 db_session: AsyncSession,
4550 ) -> None:
4551 """Repo home page embeds JS that renders the recent commits section."""
4552 await _make_repo(db_session)
4553 response = await client.get("/musehub/ui/testuser/test-beats")
4554 assert response.status_code == 200
4555 body = response.text
4556 assert "recent-commits" in body
4557 assert "loadRecentCommits" in body
4558
4559
4560 @pytest.mark.anyio
4561 async def test_repo_home_audio_player(
4562 client: AsyncClient,
4563 db_session: AsyncSession,
4564 ) -> None:
4565 """Repo home page embeds the audio player section and JS loader."""
4566 await _make_repo(db_session)
4567 response = await client.get("/musehub/ui/testuser/test-beats")
4568 assert response.status_code == 200
4569 body = response.text
4570 assert "audio-player-section" in body
4571 assert "loadAudioPlayer" in body
4572
4573
4574 @pytest.mark.anyio
4575 async def test_repo_page_json_accept(
4576 client: AsyncClient,
4577 db_session: AsyncSession,
4578 ) -> None:
4579 """GET /musehub/ui/{owner}/{repo_slug} with Accept: application/json returns JSON repo data."""
4580 await _make_repo(db_session)
4581 response = await client.get(
4582 "/musehub/ui/testuser/test-beats",
4583 headers={"Accept": "application/json"},
4584 )
4585 assert response.status_code == 200
4586 assert "application/json" in response.headers["content-type"]
4587 data = response.json()
4588 # RepoResponse fields serialised as camelCase
4589 assert "repoId" in data or "repo_id" in data or "slug" in data or "name" in data
4590
4591
4592 @pytest.mark.anyio
4593 async def test_commits_page_json_format_param(
4594 client: AsyncClient,
4595 db_session: AsyncSession,
4596 ) -> None:
4597 """GET /musehub/ui/{owner}/{repo_slug}/commits?format=json returns JSON commit list."""
4598 await _make_repo(db_session)
4599 response = await client.get("/musehub/ui/testuser/test-beats/commits?format=json")
4600 assert response.status_code == 200
4601 assert "application/json" in response.headers["content-type"]
4602 data = response.json()
4603 # CommitListResponse has commits (list) and total (int)
4604 assert "commits" in data
4605 assert "total" in data
4606 assert isinstance(data["commits"], list)
4607
4608
4609 @pytest.mark.anyio
4610 async def test_json_response_camelcase(
4611 client: AsyncClient,
4612 db_session: AsyncSession,
4613 ) -> None:
4614 """JSON response from repo page uses camelCase keys matching API convention."""
4615 await _make_repo(db_session)
4616 response = await client.get(
4617 "/musehub/ui/testuser/test-beats",
4618 headers={"Accept": "application/json"},
4619 )
4620 assert response.status_code == 200
4621 data = response.json()
4622 # All top-level keys must be camelCase — no underscores allowed in field names
4623 # (Pydantic by_alias=True serialises snake_case fields as camelCase)
4624 snake_keys = [k for k in data if "_" in k]
4625 assert snake_keys == [], f"Expected camelCase keys but found snake_case: {snake_keys}"
4626
4627
4628 @pytest.mark.anyio
4629 async def test_commits_list_html_default(
4630 client: AsyncClient,
4631 db_session: AsyncSession,
4632 ) -> None:
4633 """GET /musehub/ui/{owner}/{repo_slug}/commits with no Accept header returns HTML."""
4634 await _make_repo(db_session)
4635 response = await client.get("/musehub/ui/testuser/test-beats/commits")
4636 assert response.status_code == 200
4637 assert "text/html" in response.headers["content-type"]
4638
4639
4640 # ---------------------------------------------------------------------------
4641 # Tree browser tests — # ---------------------------------------------------------------------------
4642
4643
4644 async def _seed_tree_fixtures(db_session: AsyncSession) -> str:
4645 """Seed a public repo with a branch and objects for tree browser tests.
4646
4647 Creates:
4648 - repo: testuser/tree-test (public)
4649 - branch: main (head pointing at a dummy commit)
4650 - objects: tracks/bass.mid, tracks/keys.mp3, metadata.json, cover.webp
4651 Returns repo_id.
4652 """
4653 repo = MusehubRepo(
4654 name="tree-test",
4655 owner="testuser",
4656 slug="tree-test",
4657 visibility="public",
4658 owner_user_id="test-owner",
4659 )
4660 db_session.add(repo)
4661 await db_session.flush()
4662
4663 commit = MusehubCommit(
4664 commit_id="abc123def456",
4665 repo_id=str(repo.repo_id),
4666 message="initial",
4667 branch="main",
4668 author="testuser",
4669 timestamp=datetime.now(tz=UTC),
4670 )
4671 db_session.add(commit)
4672
4673 branch = MusehubBranch(
4674 repo_id=str(repo.repo_id),
4675 name="main",
4676 head_commit_id="abc123def456",
4677 )
4678 db_session.add(branch)
4679
4680 for path, size in [
4681 ("tracks/bass.mid", 2048),
4682 ("tracks/keys.mp3", 8192),
4683 ("metadata.json", 512),
4684 ("cover.webp", 4096),
4685 ]:
4686 obj = MusehubObject(
4687 object_id=f"sha256:{path.replace('/', '_')}",
4688 repo_id=str(repo.repo_id),
4689 path=path,
4690 size_bytes=size,
4691 disk_path=f"/tmp/{path.replace('/', '_')}",
4692 )
4693 db_session.add(obj)
4694
4695 await db_session.commit()
4696 return str(repo.repo_id)
4697
4698
4699 @pytest.mark.anyio
4700 async def test_tree_root_lists_directories(
4701 client: AsyncClient,
4702 db_session: AsyncSession,
4703 ) -> None:
4704 """GET /musehub/ui/{owner}/{repo}/tree/{ref} returns 200 HTML with tree JS."""
4705 await _seed_tree_fixtures(db_session)
4706 response = await client.get("/musehub/ui/testuser/tree-test/tree/main")
4707 assert response.status_code == 200
4708 assert "text/html" in response.headers["content-type"]
4709 body = response.text
4710 assert "tree" in body
4711 assert "branch-sel" in body or "ref-selector" in body or "loadTree" in body
4712
4713
4714 @pytest.mark.anyio
4715 async def test_tree_subdirectory_lists_files(
4716 client: AsyncClient,
4717 db_session: AsyncSession,
4718 ) -> None:
4719 """GET /{owner}/{repo}/tree/{ref}/tracks returns 200 HTML for the subdirectory."""
4720 await _seed_tree_fixtures(db_session)
4721 response = await client.get("/musehub/ui/testuser/tree-test/tree/main/tracks")
4722 assert response.status_code == 200
4723 assert "text/html" in response.headers["content-type"]
4724 body = response.text
4725 assert "tracks" in body
4726 assert "loadTree" in body
4727
4728
4729 @pytest.mark.anyio
4730 async def test_tree_file_icons_by_type(
4731 client: AsyncClient,
4732 db_session: AsyncSession,
4733 ) -> None:
4734 """Tree template includes JS that maps extensions to file-type icons."""
4735 await _seed_tree_fixtures(db_session)
4736 response = await client.get("/musehub/ui/testuser/tree-test/tree/main")
4737 assert response.status_code == 200
4738 body = response.text
4739 # Piano icon for .mid files
4740 assert ".mid" in body or "midi" in body
4741 # Waveform icon for .mp3/.wav files
4742 assert ".mp3" in body or ".wav" in body
4743 # Braces for .json
4744 assert ".json" in body
4745 # Photo for images
4746 assert ".webp" in body or ".png" in body
4747
4748
4749 @pytest.mark.anyio
4750 async def test_tree_breadcrumbs_correct(
4751 client: AsyncClient,
4752 db_session: AsyncSession,
4753 ) -> None:
4754 """Tree page breadcrumb contains owner, repo, tree, and ref."""
4755 await _seed_tree_fixtures(db_session)
4756 response = await client.get("/musehub/ui/testuser/tree-test/tree/main")
4757 assert response.status_code == 200
4758 body = response.text
4759 assert "testuser" in body
4760 assert "tree-test" in body
4761 assert "tree" in body
4762 assert "main" in body
4763
4764
4765 @pytest.mark.anyio
4766 async def test_tree_json_response(
4767 client: AsyncClient,
4768 db_session: AsyncSession,
4769 ) -> None:
4770 """GET /api/v1/musehub/repos/{repo_id}/tree/{ref} returns JSON with tree entries."""
4771 repo_id = await _seed_tree_fixtures(db_session)
4772 response = await client.get(
4773 f"/api/v1/musehub/repos/{repo_id}/tree/main"
4774 f"?owner=testuser&repo_slug=tree-test"
4775 )
4776 assert response.status_code == 200
4777 data = response.json()
4778 assert "entries" in data
4779 assert data["ref"] == "main"
4780 assert data["dirPath"] == ""
4781 # Root should show: 'tracks' dir, 'metadata.json', 'cover.webp'
4782 names = {e["name"] for e in data["entries"]}
4783 assert "tracks" in names
4784 assert "metadata.json" in names
4785 assert "cover.webp" in names
4786 # 'bass.mid' should NOT appear at root (it's under tracks/)
4787 assert "bass.mid" not in names
4788 # tracks entry must be a directory
4789 tracks_entry = next(e for e in data["entries"] if e["name"] == "tracks")
4790 assert tracks_entry["type"] == "dir"
4791 assert tracks_entry["sizeBytes"] is None
4792
4793
4794 @pytest.mark.anyio
4795 async def test_tree_unknown_ref_404(
4796 client: AsyncClient,
4797 db_session: AsyncSession,
4798 ) -> None:
4799 """GET /api/v1/musehub/repos/{repo_id}/tree/{unknown_ref} returns 404."""
4800 repo_id = await _seed_tree_fixtures(db_session)
4801 response = await client.get(
4802 f"/api/v1/musehub/repos/{repo_id}/tree/does-not-exist"
4803 f"?owner=testuser&repo_slug=tree-test"
4804 )
4805 assert response.status_code == 404
4806
4807
4808 # ---------------------------------------------------------------------------
4809 # Harmony analysis page tests — # ---------------------------------------------------------------------------
4810
4811
4812 @pytest.mark.anyio
4813 async def test_harmony_page_renders(
4814 client: AsyncClient,
4815 db_session: AsyncSession,
4816 ) -> None:
4817 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/harmony returns 200 SSR HTML."""
4818 await _make_repo(db_session)
4819 ref = "abc1234567890abcdef"
4820 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4821 assert response.status_code == 200
4822 assert "text/html" in response.headers["content-type"]
4823 body = response.text
4824 assert "Muse Hub" in body
4825 assert "Harmony Analysis" in body
4826
4827
4828 @pytest.mark.anyio
4829 async def test_harmony_page_no_auth_required(
4830 client: AsyncClient,
4831 db_session: AsyncSession,
4832 ) -> None:
4833 """Harmony analysis SSR page must be accessible without a JWT (not 401)."""
4834 await _make_repo(db_session)
4835 ref = "deadbeef00001234"
4836 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4837 assert response.status_code != 401
4838 assert response.status_code == 200
4839
4840
4841 @pytest.mark.anyio
4842 async def test_harmony_page_contains_key_display(
4843 client: AsyncClient,
4844 db_session: AsyncSession,
4845 ) -> None:
4846 """Harmony SSR page must render key and mode summary from HarmonyAnalysisResponse."""
4847 await _make_repo(db_session)
4848 ref = "cafe0000000000000001"
4849 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4850 assert response.status_code == 200
4851 body = response.text
4852 # SSR template renders key summary card with harmony_data.key (full key label e.g. "F major"),
4853 # harmony_data.mode (e.g. "major"), and harmonic_rhythm_bpm as "chords/min"
4854 assert "Harmony Analysis" in body
4855 assert "CHORD EVENTS" in body
4856 assert "chords/min" in body
4857
4858
4859 @pytest.mark.anyio
4860 async def test_harmony_page_contains_chord_timeline(
4861 client: AsyncClient,
4862 db_session: AsyncSession,
4863 ) -> None:
4864 """Harmony SSR page must render the Roman-numeral chord events section."""
4865 await _make_repo(db_session)
4866 ref = "babe0000000000000002"
4867 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4868 assert response.status_code == 200
4869 body = response.text
4870 # SSR template renders a CHORD EVENTS card with Roman numeral symbols
4871 assert "CHORD EVENTS" in body
4872
4873
4874 @pytest.mark.anyio
4875 async def test_harmony_page_contains_tension_curve(
4876 client: AsyncClient,
4877 db_session: AsyncSession,
4878 ) -> None:
4879 """Harmony SSR page must render the cadences section (replaces the old tension-curve card)."""
4880 await _make_repo(db_session)
4881 ref = "face0000000000000003"
4882 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4883 assert response.status_code == 200
4884 body = response.text
4885 # SSR template renders a CADENCES card (server-side, no JS SVG renderer needed)
4886 assert "CADENCES" in body
4887
4888
4889 @pytest.mark.anyio
4890 async def test_harmony_page_contains_modulation_section(
4891 client: AsyncClient,
4892 db_session: AsyncSession,
4893 ) -> None:
4894 """Harmony SSR page must render the MODULATIONS card server-side."""
4895 await _make_repo(db_session)
4896 ref = "feed0000000000000004"
4897 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4898 assert response.status_code == 200
4899 body = response.text
4900 # SSR template renders a MODULATIONS card from harmony_data.modulations
4901 assert "MODULATIONS" in body
4902
4903
4904 @pytest.mark.anyio
4905 async def test_harmony_page_contains_filter_controls(
4906 client: AsyncClient,
4907 db_session: AsyncSession,
4908 ) -> None:
4909 """Harmony SSR page must include HTMX fragment support (HX-Request returns partial HTML)."""
4910 await _make_repo(db_session)
4911 ref = "beef0000000000000005"
4912 # Full page response
4913 full = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4914 assert full.status_code == 200
4915 assert "<html" in full.text
4916 # HTMX fragment response (no outer HTML wrapper)
4917 fragment = await client.get(
4918 f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony",
4919 headers={"HX-Request": "true"},
4920 )
4921 assert fragment.status_code == 200
4922 assert "<html" not in fragment.text
4923 assert "Harmony Analysis" in fragment.text
4924
4925
4926 @pytest.mark.anyio
4927 async def test_harmony_page_contains_key_history(
4928 client: AsyncClient,
4929 db_session: AsyncSession,
4930 ) -> None:
4931 """Harmony SSR page must render breadcrumb with owner/repo_slug/analysis path."""
4932 await _make_repo(db_session)
4933 ref = "0000000000000000dead"
4934 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4935 assert response.status_code == 200
4936 body = response.text
4937 # SSR template breadcrumb shows owner, repo_slug, and analysis path
4938 assert "testuser" in body
4939 assert "test-beats" in body
4940 assert "analysis" in body
4941
4942
4943 @pytest.mark.anyio
4944 async def test_harmony_page_contains_voice_leading(
4945 client: AsyncClient,
4946 db_session: AsyncSession,
4947 ) -> None:
4948 """Harmony SSR page must render harmonic rhythm (replaces the old voice-leading JS card)."""
4949 await _make_repo(db_session)
4950 ref = "1111111111111111beef"
4951 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4952 assert response.status_code == 200
4953 body = response.text
4954 # SSR template renders harmonic_rhythm_bpm as "chords/min" in the key summary card
4955 assert "chords/min" in body
4956
4957
4958 @pytest.mark.anyio
4959 async def test_harmony_page_has_token_form(
4960 client: AsyncClient,
4961 db_session: AsyncSession,
4962 ) -> None:
4963 """Harmony SSR page must include JWT token form and musehub.js via base.html layout.
4964
4965 Auth state (localStorage / musehub_token) is managed by musehub.js; the
4966 base layout must include the token-form element and the musehub.js script tag.
4967 """
4968 await _make_repo(db_session)
4969 ref = "2222222222222222cafe"
4970 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/harmony")
4971 assert response.status_code == 200
4972 body = response.text
4973 assert 'id="token-form"' in body
4974 assert "musehub.js" in body
4975
4976
4977 @pytest.mark.skip(reason="Pre-existing failure: harmony API response missing 'dimension' field")
4978 @pytest.mark.anyio
4979 async def test_harmony_json_response(
4980 client: AsyncClient,
4981 db_session: AsyncSession,
4982 auth_headers: dict[str, str],
4983 ) -> None:
4984 """GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/harmony returns full harmonic JSON."""
4985 repo_id = await _make_repo(db_session)
4986 resp = await client.get(
4987 f"/api/v1/musehub/repos/{repo_id}/analysis/main/harmony",
4988 headers=auth_headers,
4989 )
4990 assert resp.status_code == 200
4991 body = resp.json()
4992 assert body["dimension"] == "harmony"
4993 assert body["ref"] == "main"
4994 data = body["data"]
4995 # Key and mode present
4996 assert "tonic" in data
4997 assert "mode" in data
4998 assert "keyConfidence" in data
4999 # Chord progression
5000 assert "chordProgression" in data
5001 assert isinstance(data["chordProgression"], list)
5002 if data["chordProgression"]:
5003 chord = data["chordProgression"][0]
5004 assert "beat" in chord
5005 assert "chord" in chord
5006 assert "function" in chord
5007 assert "tension" in chord
5008 # Tension curve
5009 assert "tensionCurve" in data
5010 assert isinstance(data["tensionCurve"], list)
5011 # Modulation points
5012 assert "modulationPoints" in data
5013 assert isinstance(data["modulationPoints"], list)
5014 # Total beats
5015 assert "totalBeats" in data
5016 assert data["totalBeats"] > 0
5017
5018 # Listen page tests
5019 # ---------------------------------------------------------------------------
5020
5021
5022 async def _seed_listen_fixtures(db_session: AsyncSession) -> str:
5023 """Seed a repo with audio objects for listen-page tests; return repo_id."""
5024 repo = MusehubRepo(
5025 name="listen-test",
5026 owner="testuser",
5027 slug="listen-test",
5028 visibility="public",
5029 owner_user_id="test-owner",
5030 )
5031 db_session.add(repo)
5032 await db_session.commit()
5033 await db_session.refresh(repo)
5034 repo_id = str(repo.repo_id)
5035
5036 for path, size in [
5037 ("mix/full_mix.mp3", 204800),
5038 ("tracks/bass.mp3", 51200),
5039 ("tracks/keys.mp3", 61440),
5040 ("tracks/bass.webp", 8192),
5041 ]:
5042 obj = MusehubObject(
5043 object_id=f"sha256:{path.replace('/', '_')}",
5044 repo_id=repo_id,
5045 path=path,
5046 size_bytes=size,
5047 disk_path=f"/tmp/{path.replace('/', '_')}",
5048 )
5049 db_session.add(obj)
5050 await db_session.commit()
5051 return repo_id
5052
5053
5054 @pytest.mark.anyio
5055 async def test_listen_page_full_mix(
5056 client: AsyncClient,
5057 db_session: AsyncSession,
5058 ) -> None:
5059 """GET /musehub/ui/{owner}/{repo}/listen/{ref} returns 200 HTML with player UI."""
5060 await _seed_listen_fixtures(db_session)
5061 ref = "main"
5062 response = await client.get(f"/musehub/ui/testuser/listen-test/listen/{ref}")
5063 assert response.status_code == 200
5064 assert "text/html" in response.headers["content-type"]
5065 body = response.text
5066 assert "Muse Hub" in body
5067 assert "listen" in body.lower()
5068 # Full-mix player elements present
5069 assert "mix-play-btn" in body
5070 assert "mix-progress-bar" in body
5071
5072
5073 @pytest.mark.anyio
5074 async def test_listen_page_track_listing(
5075 client: AsyncClient,
5076 db_session: AsyncSession,
5077 ) -> None:
5078 """Listen page HTML embeds track-listing JS that renders per-track controls."""
5079 await _seed_listen_fixtures(db_session)
5080 ref = "main"
5081 response = await client.get(f"/musehub/ui/testuser/listen-test/listen/{ref}")
5082 assert response.status_code == 200
5083 body = response.text
5084 # Track-listing JavaScript is embedded
5085 assert "track-list" in body
5086 assert "track-play-btn" in body or "playTrack" in body
5087
5088
5089 @pytest.mark.anyio
5090 async def test_listen_page_no_renders_fallback(
5091 client: AsyncClient,
5092 db_session: AsyncSession,
5093 ) -> None:
5094 """Listen page renders a friendly fallback when no audio artifacts exist."""
5095 # Repo with no objects at all
5096 repo = MusehubRepo(
5097 name="silent-repo",
5098 owner="testuser",
5099 slug="silent-repo",
5100 visibility="public",
5101 owner_user_id="test-owner",
5102 )
5103 db_session.add(repo)
5104 await db_session.commit()
5105
5106 response = await client.get("/musehub/ui/testuser/silent-repo/listen/main")
5107 assert response.status_code == 200
5108 body = response.text
5109 # Fallback UI marker present (no-renders state)
5110 assert "no-renders" in body or "No audio" in body or "hasRenders" in body
5111
5112
5113 @pytest.mark.anyio
5114 async def test_listen_page_json_response(
5115 client: AsyncClient,
5116 db_session: AsyncSession,
5117 ) -> None:
5118 """GET /musehub/ui/{owner}/{repo}/listen/{ref}?format=json returns TrackListingResponse."""
5119 await _seed_listen_fixtures(db_session)
5120 ref = "main"
5121 response = await client.get(
5122 f"/musehub/ui/testuser/listen-test/listen/{ref}",
5123 params={"format": "json"},
5124 )
5125 assert response.status_code == 200
5126 assert "application/json" in response.headers["content-type"]
5127 body = response.json()
5128 assert "repoId" in body
5129 assert "ref" in body
5130 assert body["ref"] == ref
5131 assert "tracks" in body
5132 assert "hasRenders" in body
5133 assert isinstance(body["tracks"], list)
5134
5135
5136 # ---------------------------------------------------------------------------
5137 # Issue #366 — musehub_listen service function (direct unit tests)
5138 # ---------------------------------------------------------------------------
5139
5140
5141 @pytest.mark.anyio
5142 async def test_build_track_listing_returns_full_mix_and_tracks(
5143 db_session: AsyncSession,
5144 ) -> None:
5145 """build_track_listing() returns a populated TrackListingResponse with mix + stems."""
5146 from musehub.services.musehub_listen import build_track_listing
5147
5148 repo = MusehubRepo(
5149 name="svc-listen-test",
5150 owner="svcuser",
5151 slug="svc-listen-test",
5152 visibility="public",
5153 owner_user_id="svc-owner",
5154 )
5155 db_session.add(repo)
5156 await db_session.commit()
5157 await db_session.refresh(repo)
5158 repo_id = str(repo.repo_id)
5159
5160 for path, size in [
5161 ("mix/full_mix.mp3", 204800),
5162 ("tracks/bass.mp3", 51200),
5163 ("tracks/keys.mp3", 61440),
5164 ("tracks/bass.webp", 8192),
5165 ]:
5166 obj = MusehubObject(
5167 object_id=f"sha256:svc_{path.replace('/', '_')}",
5168 repo_id=repo_id,
5169 path=path,
5170 size_bytes=size,
5171 disk_path=f"/tmp/svc_{path.replace('/', '_')}",
5172 )
5173 db_session.add(obj)
5174 await db_session.commit()
5175
5176 result = await build_track_listing(db_session, repo_id, "main")
5177
5178 assert result.has_renders is True
5179 assert result.repo_id == repo_id
5180 assert result.ref == "main"
5181 # full-mix URL points to the mix file (contains "mix" keyword)
5182 assert result.full_mix_url is not None
5183 assert "full_mix" in result.full_mix_url or "mix" in result.full_mix_url
5184 # Two audio tracks (bass.mp3 + keys.mp3); bass.webp is not audio
5185 assert len(result.tracks) == 3 # mix/full_mix.mp3, tracks/bass.mp3, tracks/keys.mp3
5186 track_paths = {t.path for t in result.tracks}
5187 assert "tracks/bass.mp3" in track_paths
5188 assert "tracks/keys.mp3" in track_paths
5189 # Piano-roll URL attached to bass.mp3 (matching bass.webp exists)
5190 bass_track = next(t for t in result.tracks if t.path == "tracks/bass.mp3")
5191 assert bass_track.piano_roll_url is not None
5192
5193
5194 @pytest.mark.anyio
5195 async def test_build_track_listing_no_audio_returns_empty(
5196 db_session: AsyncSession,
5197 ) -> None:
5198 """build_track_listing() returns has_renders=False when no audio objects exist."""
5199 from musehub.services.musehub_listen import build_track_listing
5200
5201 repo = MusehubRepo(
5202 name="svc-silent-test",
5203 owner="svcuser",
5204 slug="svc-silent-test",
5205 visibility="public",
5206 owner_user_id="svc-owner",
5207 )
5208 db_session.add(repo)
5209 await db_session.commit()
5210 await db_session.refresh(repo)
5211 repo_id = str(repo.repo_id)
5212
5213 # Only a non-audio object
5214 obj = MusehubObject(
5215 object_id="sha256:svc_midi",
5216 repo_id=repo_id,
5217 path="tracks/bass.mid",
5218 size_bytes=1024,
5219 disk_path="/tmp/svc_bass.mid",
5220 )
5221 db_session.add(obj)
5222 await db_session.commit()
5223
5224 result = await build_track_listing(db_session, repo_id, "dev")
5225
5226 assert result.has_renders is False
5227 assert result.full_mix_url is None
5228 assert result.tracks == []
5229
5230
5231 @pytest.mark.anyio
5232 async def test_build_track_listing_no_mix_keyword_uses_first_alphabetically(
5233 db_session: AsyncSession,
5234 ) -> None:
5235 """When no file matches _FULL_MIX_KEYWORDS, the first audio file (by path) is used."""
5236 from musehub.services.musehub_listen import build_track_listing
5237
5238 repo = MusehubRepo(
5239 name="svc-nomix-test",
5240 owner="svcuser",
5241 slug="svc-nomix-test",
5242 visibility="public",
5243 owner_user_id="svc-owner",
5244 )
5245 db_session.add(repo)
5246 await db_session.commit()
5247 await db_session.refresh(repo)
5248 repo_id = str(repo.repo_id)
5249
5250 for path, size in [
5251 ("tracks/bass.mp3", 51200),
5252 ("tracks/drums.mp3", 61440),
5253 ]:
5254 obj = MusehubObject(
5255 object_id=f"sha256:svc_nomix_{path.replace('/', '_')}",
5256 repo_id=repo_id,
5257 path=path,
5258 size_bytes=size,
5259 disk_path=f"/tmp/svc_nomix_{path.replace('/', '_')}",
5260 )
5261 db_session.add(obj)
5262 await db_session.commit()
5263
5264 result = await build_track_listing(db_session, repo_id, "main")
5265
5266 assert result.has_renders is True
5267 # 'tracks/bass.mp3' sorts before 'tracks/drums.mp3'
5268 assert result.full_mix_url is not None
5269 assert "bass" in result.full_mix_url
5270
5271
5272 # ---------------------------------------------------------------------------
5273 # Issue #206 — Commit list page
5274 # ---------------------------------------------------------------------------
5275
5276 _COMMIT_LIST_OWNER = "commitowner"
5277 _COMMIT_LIST_SLUG = "commit-list-repo"
5278 _SHA_MAIN_1 = "aa001122334455667788990011223344556677889900"
5279 _SHA_MAIN_2 = "bb001122334455667788990011223344556677889900"
5280 _SHA_MAIN_MERGE = "cc001122334455667788990011223344556677889900"
5281 _SHA_FEAT = "ff001122334455667788990011223344556677889900"
5282
5283
5284 async def _seed_commit_list_repo(
5285 db_session: AsyncSession,
5286 ) -> str:
5287 """Seed a repo with 2 commits on main, 1 merge commit, and 1 on feat branch."""
5288 repo = MusehubRepo(
5289 name=_COMMIT_LIST_SLUG,
5290 owner=_COMMIT_LIST_OWNER,
5291 slug=_COMMIT_LIST_SLUG,
5292 visibility="public",
5293 owner_user_id="commit-owner-uid",
5294 )
5295 db_session.add(repo)
5296 await db_session.flush()
5297 repo_id = str(repo.repo_id)
5298
5299 branch_main = MusehubBranch(repo_id=repo_id, name="main", head_commit_id=_SHA_MAIN_MERGE)
5300 branch_feat = MusehubBranch(repo_id=repo_id, name="feat/drums", head_commit_id=_SHA_FEAT)
5301 db_session.add_all([branch_main, branch_feat])
5302
5303 now = datetime.now(UTC)
5304 commits = [
5305 MusehubCommit(
5306 commit_id=_SHA_MAIN_1,
5307 repo_id=repo_id,
5308 branch="main",
5309 parent_ids=[],
5310 message="feat(bass): root commit with walking bass line",
5311 author="composer@stori.io",
5312 timestamp=now - timedelta(hours=4),
5313 ),
5314 MusehubCommit(
5315 commit_id=_SHA_MAIN_2,
5316 repo_id=repo_id,
5317 branch="main",
5318 parent_ids=[_SHA_MAIN_1],
5319 message="feat(keys): add rhodes chord voicings in verse",
5320 author="composer@stori.io",
5321 timestamp=now - timedelta(hours=2),
5322 ),
5323 MusehubCommit(
5324 commit_id=_SHA_MAIN_MERGE,
5325 repo_id=repo_id,
5326 branch="main",
5327 parent_ids=[_SHA_MAIN_2, _SHA_FEAT],
5328 message="merge(feat/drums): integrate drum pattern into main",
5329 author="composer@stori.io",
5330 timestamp=now - timedelta(hours=1),
5331 ),
5332 MusehubCommit(
5333 commit_id=_SHA_FEAT,
5334 repo_id=repo_id,
5335 branch="feat/drums",
5336 parent_ids=[_SHA_MAIN_1],
5337 message="feat(drums): add kick and snare pattern at 120 BPM",
5338 author="drummer@stori.io",
5339 timestamp=now - timedelta(hours=3),
5340 ),
5341 ]
5342 db_session.add_all(commits)
5343 await db_session.commit()
5344 return repo_id
5345
5346
5347 @pytest.mark.anyio
5348 async def test_commits_list_page_returns_200(
5349 client: AsyncClient,
5350 db_session: AsyncSession,
5351 ) -> None:
5352 """GET /{owner}/{repo}/commits returns 200 HTML."""
5353 await _seed_commit_list_repo(db_session)
5354 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5355 assert resp.status_code == 200
5356 assert "text/html" in resp.headers["content-type"]
5357 assert "Muse Hub" in resp.text
5358
5359
5360 @pytest.mark.anyio
5361 async def test_commits_list_page_shows_commit_sha(
5362 client: AsyncClient,
5363 db_session: AsyncSession,
5364 ) -> None:
5365 """Commit SHA (first 8 chars) appears in the rendered HTML."""
5366 await _seed_commit_list_repo(db_session)
5367 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5368 assert resp.status_code == 200
5369 # All 4 commits should appear (per_page=30 default, total=4)
5370 assert _SHA_MAIN_1[:8] in resp.text
5371 assert _SHA_MAIN_2[:8] in resp.text
5372 assert _SHA_MAIN_MERGE[:8] in resp.text
5373 assert _SHA_FEAT[:8] in resp.text
5374
5375
5376 @pytest.mark.anyio
5377 async def test_commits_list_page_shows_commit_message(
5378 client: AsyncClient,
5379 db_session: AsyncSession,
5380 ) -> None:
5381 """Commit messages appear truncated in commit rows."""
5382 await _seed_commit_list_repo(db_session)
5383 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5384 assert resp.status_code == 200
5385 assert "walking bass line" in resp.text
5386 assert "rhodes chord voicings" in resp.text
5387
5388
5389 @pytest.mark.anyio
5390 async def test_commits_list_page_dag_indicator(
5391 client: AsyncClient,
5392 db_session: AsyncSession,
5393 ) -> None:
5394 """DAG node CSS class is present in the HTML for every commit row."""
5395 await _seed_commit_list_repo(db_session)
5396 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5397 assert resp.status_code == 200
5398 assert "dag-node" in resp.text
5399 assert "commit-list-row" in resp.text
5400
5401
5402 @pytest.mark.anyio
5403 async def test_commits_list_page_merge_indicator(
5404 client: AsyncClient,
5405 db_session: AsyncSession,
5406 ) -> None:
5407 """Merge commits display the merge indicator and dag-node-merge class."""
5408 await _seed_commit_list_repo(db_session)
5409 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5410 assert resp.status_code == 200
5411 assert "dag-node-merge" in resp.text
5412 assert "merge" in resp.text.lower()
5413
5414
5415 @pytest.mark.anyio
5416 async def test_commits_list_page_branch_selector(
5417 client: AsyncClient,
5418 db_session: AsyncSession,
5419 ) -> None:
5420 """Branch <select> dropdown is present when the repo has branches."""
5421 await _seed_commit_list_repo(db_session)
5422 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5423 assert resp.status_code == 200
5424 # Select element with branch options
5425 assert "branch-sel" in resp.text
5426 assert "main" in resp.text
5427 assert "feat/drums" in resp.text
5428
5429
5430 @pytest.mark.anyio
5431 async def test_commits_list_page_graph_link(
5432 client: AsyncClient,
5433 db_session: AsyncSession,
5434 ) -> None:
5435 """Link to the DAG graph page is present."""
5436 await _seed_commit_list_repo(db_session)
5437 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5438 assert resp.status_code == 200
5439 assert "/graph" in resp.text
5440
5441
5442 @pytest.mark.anyio
5443 async def test_commits_list_page_pagination_links(
5444 client: AsyncClient,
5445 db_session: AsyncSession,
5446 ) -> None:
5447 """Pagination nav links appear when total exceeds per_page."""
5448 await _seed_commit_list_repo(db_session)
5449 # Request per_page=2 so 4 commits produce 2 pages
5450 resp = await client.get(
5451 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=1"
5452 )
5453 assert resp.status_code == 200
5454 body = resp.text
5455 # "Older" link should be active (page 1 has no "Newer")
5456 assert "Older" in body
5457 # "Newer" should be disabled on page 1
5458 assert "Newer" in body
5459 assert "page=2" in body
5460
5461
5462 @pytest.mark.anyio
5463 async def test_commits_list_page_pagination_page2(
5464 client: AsyncClient,
5465 db_session: AsyncSession,
5466 ) -> None:
5467 """Page 2 renders with Newer navigation active."""
5468 await _seed_commit_list_repo(db_session)
5469 resp = await client.get(
5470 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=2"
5471 )
5472 assert resp.status_code == 200
5473 body = resp.text
5474 assert "page=1" in body # "Newer" link points back to page 1
5475
5476
5477 @pytest.mark.anyio
5478 async def test_commits_list_page_branch_filter_html(
5479 client: AsyncClient,
5480 db_session: AsyncSession,
5481 ) -> None:
5482 """?branch=main returns only main-branch commits in HTML."""
5483 await _seed_commit_list_repo(db_session)
5484 resp = await client.get(
5485 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?branch=main"
5486 )
5487 assert resp.status_code == 200
5488 body = resp.text
5489 # main commits appear
5490 assert _SHA_MAIN_1[:8] in body
5491 assert _SHA_MAIN_2[:8] in body
5492 assert _SHA_MAIN_MERGE[:8] in body
5493 # feat/drums commit should NOT appear when filtered to main
5494 assert _SHA_FEAT[:8] not in body
5495
5496
5497 @pytest.mark.anyio
5498 async def test_commits_list_page_json_content_negotiation(
5499 client: AsyncClient,
5500 db_session: AsyncSession,
5501 ) -> None:
5502 """?format=json returns CommitListResponse JSON with commits and total."""
5503 await _seed_commit_list_repo(db_session)
5504 resp = await client.get(
5505 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?format=json"
5506 )
5507 assert resp.status_code == 200
5508 assert "application/json" in resp.headers["content-type"]
5509 body = resp.json()
5510 assert "commits" in body
5511 assert "total" in body
5512 assert body["total"] == 4
5513 assert len(body["commits"]) == 4
5514 # Commits are newest first; merge commit has timestamp now-1h (most recent)
5515 commit_ids = [c["commitId"] for c in body["commits"]]
5516 assert commit_ids[0] == _SHA_MAIN_MERGE
5517
5518
5519 @pytest.mark.anyio
5520 async def test_commits_list_page_json_pagination(
5521 client: AsyncClient,
5522 db_session: AsyncSession,
5523 ) -> None:
5524 """JSON with per_page=1&page=2 returns the second commit."""
5525 await _seed_commit_list_repo(db_session)
5526 resp = await client.get(
5527 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits"
5528 "?format=json&per_page=1&page=2"
5529 )
5530 assert resp.status_code == 200
5531 body = resp.json()
5532 assert body["total"] == 4
5533 assert len(body["commits"]) == 1
5534 # Page 2 (newest-first) is the second most-recent commit.
5535 # Newest: _SHA_MAIN_MERGE (now-1h), then _SHA_MAIN_2 (now-2h)
5536 assert body["commits"][0]["commitId"] == _SHA_MAIN_2
5537
5538
5539 @pytest.mark.anyio
5540 async def test_commits_list_page_empty_state(
5541 client: AsyncClient,
5542 db_session: AsyncSession,
5543 ) -> None:
5544 """A repo with no commits shows the empty state message."""
5545 repo = MusehubRepo(
5546 name="empty-repo",
5547 owner="emptyowner",
5548 slug="empty-repo",
5549 visibility="public",
5550 owner_user_id="empty-owner-uid",
5551 )
5552 db_session.add(repo)
5553 await db_session.commit()
5554
5555 resp = await client.get("/musehub/ui/emptyowner/empty-repo/commits")
5556 assert resp.status_code == 200
5557 assert "No commits yet" in resp.text or "muse push" in resp.text
5558
5559
5560 # ---------------------------------------------------------------------------
5561
5562
5563
5564 # ---------------------------------------------------------------------------
5565 # Commit detail enhancements — # ---------------------------------------------------------------------------
5566
5567
5568 async def _seed_commit_detail_fixtures(
5569 db_session: AsyncSession,
5570 ) -> tuple[str, str, str]:
5571 """Seed a public repo with a parent commit and a child commit.
5572
5573 Returns (repo_id, parent_commit_id, child_commit_id).
5574 """
5575 repo = MusehubRepo(
5576 name="commit-detail-test",
5577 owner="testuser",
5578 slug="commit-detail-test",
5579 visibility="public",
5580 owner_user_id="test-owner",
5581 )
5582 db_session.add(repo)
5583 await db_session.flush()
5584 repo_id = str(repo.repo_id)
5585
5586 branch = MusehubBranch(
5587 repo_id=repo_id,
5588 name="main",
5589 head_commit_id=None,
5590 )
5591 db_session.add(branch)
5592
5593 parent_commit_id = "aaaa0000111122223333444455556666aaaabbbb"
5594 child_commit_id = "bbbb1111222233334444555566667777bbbbcccc"
5595
5596 parent_commit = MusehubCommit(
5597 repo_id=repo_id,
5598 commit_id=parent_commit_id,
5599 branch="main",
5600 parent_ids=[],
5601 message="init: establish harmonic foundation in C major\n\nKey: C major\nBPM: 120\nMeter: 4/4",
5602 author="testuser",
5603 timestamp=datetime.now(UTC) - timedelta(hours=2),
5604 snapshot_id=None,
5605 )
5606 child_commit = MusehubCommit(
5607 repo_id=repo_id,
5608 commit_id=child_commit_id,
5609 branch="main",
5610 parent_ids=[parent_commit_id],
5611 message="feat(keys): add melodic piano phrase in D minor\n\nKey: D minor\nBPM: 132\nMeter: 3/4\nSection: verse",
5612 author="testuser",
5613 timestamp=datetime.now(UTC) - timedelta(hours=1),
5614 snapshot_id=None,
5615 )
5616 db_session.add(parent_commit)
5617 db_session.add(child_commit)
5618 await db_session.commit()
5619 return repo_id, parent_commit_id, child_commit_id
5620
5621
5622 @pytest.mark.anyio
5623 async def test_commit_detail_page_renders_enhanced_metadata(
5624 client: AsyncClient,
5625 db_session: AsyncSession,
5626 ) -> None:
5627 """Commit detail page SSR renders commit header fields (SHA, author, branch, parent link)."""
5628 await _seed_commit_detail_fixtures(db_session)
5629 sha = "bbbb1111222233334444555566667777bbbbcccc"
5630 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
5631 assert response.status_code == 200
5632 assert "text/html" in response.headers["content-type"]
5633 body = response.text
5634 # SSR commit header — short SHA present
5635 assert "bbbb1111" in body
5636 # Author field rendered server-side
5637 assert "testuser" in body
5638 # Parent SHA navigation link present
5639 assert "aaaa0000" in body
5640
5641
5642 @pytest.mark.anyio
5643 async def test_commit_detail_audio_shell_with_snapshot_id(
5644 client: AsyncClient,
5645 db_session: AsyncSession,
5646 ) -> None:
5647 """Commit with snapshot_id gets a WaveSurfer shell rendered by the server."""
5648 from datetime import datetime, timezone
5649
5650 _repo_id, _parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5651 repo = MusehubRepo(
5652 name="audio-test-repo",
5653 owner="testuser",
5654 slug="audio-test-repo",
5655 visibility="public",
5656 owner_user_id="test-owner",
5657 )
5658 db_session.add(repo)
5659 await db_session.flush()
5660 snap_id = "sha256:deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678"
5661 commit_with_audio = MusehubCommit(
5662 commit_id="cccc2222333344445555666677778888ccccdddd",
5663 repo_id=str(repo.repo_id),
5664 branch="main",
5665 parent_ids=[],
5666 message="Commit with audio snapshot",
5667 author="testuser",
5668 timestamp=datetime.now(tz=timezone.utc),
5669 snapshot_id=snap_id,
5670 )
5671 db_session.add(commit_with_audio)
5672 await db_session.commit()
5673
5674 response = await client.get(
5675 f"/musehub/ui/testuser/audio-test-repo/commits/cccc2222333344445555666677778888ccccdddd"
5676 )
5677 assert response.status_code == 200
5678 body = response.text
5679 assert "commit-waveform" in body
5680 assert snap_id in body
5681
5682
5683 @pytest.mark.anyio
5684 async def test_commit_detail_ssr_message_present_in_body(
5685 client: AsyncClient,
5686 db_session: AsyncSession,
5687 ) -> None:
5688 """Commit message text is rendered in the SSR page body (replaces JS renderCommitBody)."""
5689 await _seed_commit_detail_fixtures(db_session)
5690 sha = "bbbb1111222233334444555566667777bbbbcccc"
5691 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
5692 assert response.status_code == 200
5693 body = response.text
5694 # SSR renders the commit message directly — no JS renderCommitBody needed
5695 assert "feat(keys): add melodic piano phrase in D minor" in body
5696
5697
5698 @pytest.mark.anyio
5699 async def test_commit_detail_diff_summary_endpoint_returns_five_dimensions(
5700 client: AsyncClient,
5701 db_session: AsyncSession,
5702 auth_headers: dict[str, str],
5703 ) -> None:
5704 """GET /api/v1/musehub/repos/{repo_id}/commits/{sha}/diff-summary returns 5 dimensions."""
5705 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5706 response = await client.get(
5707 f"/api/v1/musehub/repos/{repo_id}/commits/{child_id}/diff-summary",
5708 headers=auth_headers,
5709 )
5710 assert response.status_code == 200
5711 data = response.json()
5712 assert data["commitId"] == child_id
5713 assert data["parentId"] == _parent_id
5714 assert "dimensions" in data
5715 assert len(data["dimensions"]) == 5
5716 dim_names = {d["dimension"] for d in data["dimensions"]}
5717 assert dim_names == {"harmonic", "rhythmic", "melodic", "structural", "dynamic"}
5718 for dim in data["dimensions"]:
5719 assert 0.0 <= dim["score"] <= 1.0
5720 assert dim["label"] in {"none", "low", "medium", "high"}
5721 assert dim["color"] in {"dim-none", "dim-low", "dim-medium", "dim-high"}
5722 assert "overallScore" in data
5723 assert 0.0 <= data["overallScore"] <= 1.0
5724
5725
5726 @pytest.mark.anyio
5727 async def test_commit_detail_diff_summary_root_commit_scores_one(
5728 client: AsyncClient,
5729 db_session: AsyncSession,
5730 auth_headers: dict[str, str],
5731 ) -> None:
5732 """Diff summary for a root commit (no parent) scores all dimensions at 1.0."""
5733 repo_id, parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5734 response = await client.get(
5735 f"/api/v1/musehub/repos/{repo_id}/commits/{parent_id}/diff-summary",
5736 headers=auth_headers,
5737 )
5738 assert response.status_code == 200
5739 data = response.json()
5740 assert data["parentId"] is None
5741 for dim in data["dimensions"]:
5742 assert dim["score"] == 1.0
5743 assert dim["label"] == "high"
5744
5745
5746 @pytest.mark.anyio
5747 async def test_commit_detail_diff_summary_keyword_detection(
5748 client: AsyncClient,
5749 db_session: AsyncSession,
5750 auth_headers: dict[str, str],
5751 ) -> None:
5752 """Diff summary detects melodic keyword in child commit message."""
5753 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5754 response = await client.get(
5755 f"/api/v1/musehub/repos/{repo_id}/commits/{child_id}/diff-summary",
5756 headers=auth_headers,
5757 )
5758 assert response.status_code == 200
5759 data = response.json()
5760 melodic_dim = next(d for d in data["dimensions"] if d["dimension"] == "melodic")
5761 # child commit message contains "melodic" keyword → non-zero score
5762 assert melodic_dim["score"] > 0.0
5763
5764
5765 @pytest.mark.anyio
5766 async def test_commit_detail_diff_summary_unknown_commit_404(
5767 client: AsyncClient,
5768 db_session: AsyncSession,
5769 auth_headers: dict[str, str],
5770 ) -> None:
5771 """Diff summary for unknown commit ID returns 404."""
5772 repo_id, _p, _c = await _seed_commit_detail_fixtures(db_session)
5773 response = await client.get(
5774 f"/api/v1/musehub/repos/{repo_id}/commits/deadbeefdeadbeefdeadbeef/diff-summary",
5775 headers=auth_headers, )
5776 assert response.status_code == 404
5777
5778
5779 # ---------------------------------------------------------------------------
5780 # Commit comment threads — # ---------------------------------------------------------------------------
5781
5782
5783 @pytest.mark.anyio
5784 async def test_commit_page_has_comment_section_html(
5785 client: AsyncClient,
5786 db_session: AsyncSession,
5787 ) -> None:
5788 """Commit detail page HTML includes the HTMX comment target container."""
5789 from datetime import datetime, timezone
5790
5791 repo_id = await _make_repo(db_session)
5792 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5793 commit = MusehubCommit(
5794 commit_id=commit_id,
5795 repo_id=repo_id,
5796 branch="main",
5797 parent_ids=[],
5798 message="Add chorus section",
5799 author="testuser",
5800 timestamp=datetime.now(tz=timezone.utc),
5801 )
5802 db_session.add(commit)
5803 await db_session.commit()
5804
5805 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5806 assert response.status_code == 200
5807 body = response.text
5808 # SSR replaces JS-loaded comment section with a server-rendered HTMX target
5809 assert "commit-comments" in body
5810 assert "hx-target" in body
5811
5812
5813 @pytest.mark.anyio
5814 async def test_commit_page_has_htmx_comment_form(
5815 client: AsyncClient,
5816 db_session: AsyncSession,
5817 ) -> None:
5818 """Commit detail page has an HTMX-driven comment form (replaces old JS comment functions)."""
5819 from datetime import datetime, timezone
5820
5821 repo_id = await _make_repo(db_session)
5822 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5823 commit = MusehubCommit(
5824 commit_id=commit_id,
5825 repo_id=repo_id,
5826 branch="main",
5827 parent_ids=[],
5828 message="Add chorus section",
5829 author="testuser",
5830 timestamp=datetime.now(tz=timezone.utc),
5831 )
5832 db_session.add(commit)
5833 await db_session.commit()
5834
5835 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5836 assert response.status_code == 200
5837 body = response.text
5838 # HTMX form replaces JS renderComments/submitComment/loadComments
5839 assert "hx-post" in body
5840 assert "hx-target" in body
5841 assert "textarea" in body
5842
5843
5844 @pytest.mark.anyio
5845 async def test_commit_page_comment_htmx_target_present(
5846 client: AsyncClient,
5847 db_session: AsyncSession,
5848 ) -> None:
5849 """HTMX comment target div is present for server-side comment injection."""
5850 from datetime import datetime, timezone
5851
5852 repo_id = await _make_repo(db_session)
5853 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5854 commit = MusehubCommit(
5855 commit_id=commit_id,
5856 repo_id=repo_id,
5857 branch="main",
5858 parent_ids=[],
5859 message="Add chorus section",
5860 author="testuser",
5861 timestamp=datetime.now(tz=timezone.utc),
5862 )
5863 db_session.add(commit)
5864 await db_session.commit()
5865
5866 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5867 assert response.status_code == 200
5868 body = response.text
5869 assert 'id="commit-comments"' in body
5870
5871
5872 @pytest.mark.anyio
5873 async def test_commit_page_comment_htmx_posts_to_comments_endpoint(
5874 client: AsyncClient,
5875 db_session: AsyncSession,
5876 ) -> None:
5877 """HTMX form posts to the commit comments endpoint (replaces old JS API fetch)."""
5878 from datetime import datetime, timezone
5879
5880 repo_id = await _make_repo(db_session)
5881 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5882 commit = MusehubCommit(
5883 commit_id=commit_id,
5884 repo_id=repo_id,
5885 branch="main",
5886 parent_ids=[],
5887 message="Add chorus section",
5888 author="testuser",
5889 timestamp=datetime.now(tz=timezone.utc),
5890 )
5891 db_session.add(commit)
5892 await db_session.commit()
5893
5894 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5895 assert response.status_code == 200
5896 body = response.text
5897 assert "hx-post" in body
5898 assert "/comments" in body
5899
5900
5901 @pytest.mark.anyio
5902 async def test_commit_page_comment_has_ssr_avatar(
5903 client: AsyncClient,
5904 db_session: AsyncSession,
5905 ) -> None:
5906 """Commit page SSR comment thread renders avatar initials via server-side template."""
5907 from datetime import datetime, timezone
5908
5909 repo_id = await _make_repo(db_session)
5910 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5911 commit = MusehubCommit(
5912 commit_id=commit_id,
5913 repo_id=repo_id,
5914 branch="main",
5915 parent_ids=[],
5916 message="Add chorus section",
5917 author="testuser",
5918 timestamp=datetime.now(tz=timezone.utc),
5919 )
5920 db_session.add(commit)
5921 await db_session.commit()
5922
5923 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5924 assert response.status_code == 200
5925 body = response.text
5926 # SSR avatar class from commit_comments.html fragment
5927 assert "comment-avatar" in body
5928
5929
5930 @pytest.mark.anyio
5931 async def test_commit_page_comment_has_htmx_form_elements(
5932 client: AsyncClient,
5933 db_session: AsyncSession,
5934 ) -> None:
5935 """Commit page HTMX comment form has textarea and submit button."""
5936 from datetime import datetime, timezone
5937
5938 repo_id = await _make_repo(db_session)
5939 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5940 commit = MusehubCommit(
5941 commit_id=commit_id,
5942 repo_id=repo_id,
5943 branch="main",
5944 parent_ids=[],
5945 message="Add chorus section",
5946 author="testuser",
5947 timestamp=datetime.now(tz=timezone.utc),
5948 )
5949 db_session.add(commit)
5950 await db_session.commit()
5951
5952 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5953 assert response.status_code == 200
5954 body = response.text
5955 # HTMX form replaces old new-comment-form/new-comment-body/comment-submit-btn
5956 assert 'name="body"' in body
5957 assert "btn-primary" in body
5958 assert "Comment" in body
5959
5960
5961 @pytest.mark.anyio
5962 async def test_commit_page_comment_section_shows_count_heading(
5963 client: AsyncClient,
5964 db_session: AsyncSession,
5965 ) -> None:
5966 """Commit page SSR comment section shows a count heading (replaces 'Discussion' heading)."""
5967 from datetime import datetime, timezone
5968
5969 repo_id = await _make_repo(db_session)
5970 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5971 commit = MusehubCommit(
5972 commit_id=commit_id,
5973 repo_id=repo_id,
5974 branch="main",
5975 parent_ids=[],
5976 message="Add chorus section",
5977 author="testuser",
5978 timestamp=datetime.now(tz=timezone.utc),
5979 )
5980 db_session.add(commit)
5981 await db_session.commit()
5982
5983 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5984 assert response.status_code == 200
5985 body = response.text
5986 assert "comment" in body
5987
5988
5989 # ---------------------------------------------------------------------------
5990 # Commit detail enhancements — ref URL links, DB tags in panel, prose
5991 # summary
5992 # ---------------------------------------------------------------------------
5993
5994
5995 @pytest.mark.anyio
5996 async def test_commit_page_ssr_renders_commit_message(
5997 client: AsyncClient,
5998 db_session: AsyncSession,
5999 ) -> None:
6000 """Commit message is rendered server-side (replaces JS ref-tag / tagPill rendering)."""
6001 from datetime import datetime, timezone
6002
6003 repo_id = await _make_repo(db_session)
6004 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6005 commit = MusehubCommit(
6006 commit_id=commit_id,
6007 repo_id=repo_id,
6008 branch="main",
6009 parent_ids=[],
6010 message="Unique groove message XYZ",
6011 author="testuser",
6012 timestamp=datetime.now(tz=timezone.utc),
6013 )
6014 db_session.add(commit)
6015 await db_session.commit()
6016
6017 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
6018 assert response.status_code == 200
6019 body = response.text
6020 # SSR renders commit message directly — no JS tagPill/isRefUrl needed
6021 assert "Unique groove message XYZ" in body
6022
6023
6024 @pytest.mark.anyio
6025 async def test_commit_page_ssr_renders_author_metadata(
6026 client: AsyncClient,
6027 db_session: AsyncSession,
6028 ) -> None:
6029 """Commit author and branch appear in the SSR metadata grid (replaces JS muse-tags panel)."""
6030 from datetime import datetime, timezone
6031
6032 repo_id = await _make_repo(db_session)
6033 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6034 commit = MusehubCommit(
6035 commit_id=commit_id,
6036 repo_id=repo_id,
6037 branch="main",
6038 parent_ids=[],
6039 message="Add chorus section",
6040 author="jazzproducer",
6041 timestamp=datetime.now(tz=timezone.utc),
6042 )
6043 db_session.add(commit)
6044 await db_session.commit()
6045
6046 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
6047 assert response.status_code == 200
6048 body = response.text
6049 # SSR metadata grid shows author — no JS loadMuseTagsPanel needed
6050 assert "jazzproducer" in body
6051
6052
6053 @pytest.mark.anyio
6054 async def test_commit_page_no_audio_shell_when_no_snapshot(
6055 client: AsyncClient,
6056 db_session: AsyncSession,
6057 ) -> None:
6058 """Commit page without snapshot_id omits WaveSurfer shell (replaces buildProseSummary check)."""
6059 from datetime import datetime, timezone
6060
6061 repo_id = await _make_repo(db_session)
6062 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6063 commit = MusehubCommit(
6064 commit_id=commit_id,
6065 repo_id=repo_id,
6066 branch="main",
6067 parent_ids=[],
6068 message="Add chorus section",
6069 author="testuser",
6070 timestamp=datetime.now(tz=timezone.utc),
6071 snapshot_id=None,
6072 )
6073 db_session.add(commit)
6074 await db_session.commit()
6075
6076 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
6077 assert response.status_code == 200
6078 body = response.text
6079 assert "commit-waveform" not in body
6080
6081
6082 # ---------------------------------------------------------------------------
6083 # Audio player — listen page tests
6084 # ---------------------------------------------------------------------------
6085
6086
6087 @pytest.mark.anyio
6088 async def test_listen_page_renders(
6089 client: AsyncClient,
6090 db_session: AsyncSession,
6091 ) -> None:
6092 """GET /musehub/ui/{owner}/{slug}/listen/{ref} must return 200 HTML."""
6093 await _make_repo(db_session)
6094 ref = "abc1234567890abcdef"
6095 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6096 assert response.status_code == 200
6097 assert "text/html" in response.headers["content-type"]
6098
6099
6100 @pytest.mark.anyio
6101 async def test_listen_page_no_auth_required(
6102 client: AsyncClient,
6103 db_session: AsyncSession,
6104 ) -> None:
6105 """Listen page must be accessible without an Authorization header."""
6106 await _make_repo(db_session)
6107 ref = "deadbeef1234"
6108 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6109 assert response.status_code != 401
6110 assert response.status_code == 200
6111
6112
6113 @pytest.mark.anyio
6114 async def test_listen_page_contains_waveform_ui(
6115 client: AsyncClient,
6116 db_session: AsyncSession,
6117 ) -> None:
6118 """Listen page HTML must contain the waveform container element."""
6119 await _make_repo(db_session)
6120 ref = "cafebabe1234"
6121 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6122 assert response.status_code == 200
6123 body = response.text
6124 assert "waveform" in body
6125
6126
6127 @pytest.mark.anyio
6128 async def test_listen_page_contains_play_button(
6129 client: AsyncClient,
6130 db_session: AsyncSession,
6131 ) -> None:
6132 """Listen page must include a play button element."""
6133 await _make_repo(db_session)
6134 ref = "feed1234abcdef"
6135 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6136 assert response.status_code == 200
6137 body = response.text
6138 assert "play-btn" in body
6139
6140
6141 @pytest.mark.anyio
6142 async def test_listen_page_contains_speed_selector(
6143 client: AsyncClient,
6144 db_session: AsyncSession,
6145 ) -> None:
6146 """Listen page must include the playback speed selector element."""
6147 await _make_repo(db_session)
6148 ref = "1a2b3c4d5e6f7890"
6149 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6150 assert response.status_code == 200
6151 body = response.text
6152 assert "speed-sel" in body
6153
6154
6155 @pytest.mark.anyio
6156 async def test_listen_page_contains_ab_loop_ui(
6157 client: AsyncClient,
6158 db_session: AsyncSession,
6159 ) -> None:
6160 """Listen page must include A/B loop controls (loop info + clear button)."""
6161 await _make_repo(db_session)
6162 ref = "aabbccddeeff0011"
6163 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6164 assert response.status_code == 200
6165 body = response.text
6166 assert "loop-info" in body
6167 assert "loop-clear-btn" in body
6168
6169
6170 @pytest.mark.anyio
6171 async def test_listen_page_loads_wavesurfer_vendor(
6172 client: AsyncClient,
6173 db_session: AsyncSession,
6174 ) -> None:
6175 """Listen page must load the vendored wavesurfer.min.js — no external CDN."""
6176 await _make_repo(db_session)
6177 ref = "112233445566778899"
6178 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6179 assert response.status_code == 200
6180 body = response.text
6181 # Must reference the local vendor path — never an external CDN URL
6182 assert "vendor/wavesurfer.min.js" in body
6183 assert "unpkg.com" not in body
6184 assert "cdn.jsdelivr.net" not in body
6185 assert "cdnjs.cloudflare.com" not in body
6186
6187
6188 @pytest.mark.anyio
6189 async def test_listen_page_loads_audio_player_js(
6190 client: AsyncClient,
6191 db_session: AsyncSession,
6192 ) -> None:
6193 """Listen page must load the audio-player.js component wrapper script."""
6194 await _make_repo(db_session)
6195 ref = "99aabbccddeeff00"
6196 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6197 assert response.status_code == 200
6198 body = response.text
6199 assert "audio-player.js" in body
6200
6201
6202 @pytest.mark.anyio
6203 async def test_listen_track_page_renders(
6204 client: AsyncClient,
6205 db_session: AsyncSession,
6206 ) -> None:
6207 """GET /musehub/ui/{owner}/{slug}/listen/{ref}/{path} must return 200."""
6208 await _make_repo(db_session)
6209 ref = "feedface0011aabb"
6210 response = await client.get(
6211 f"/musehub/ui/testuser/test-beats/listen/{ref}/tracks/bass.mp3"
6212 )
6213 assert response.status_code == 200
6214 assert "text/html" in response.headers["content-type"]
6215
6216
6217 @pytest.mark.anyio
6218 async def test_listen_track_page_has_track_path_in_js(
6219 client: AsyncClient,
6220 db_session: AsyncSession,
6221 ) -> None:
6222 """Track path must be injected into the page JS context as TRACK_PATH."""
6223 await _make_repo(db_session)
6224 ref = "00aabbccddeeff11"
6225 track = "tracks/lead-guitar.mp3"
6226 response = await client.get(
6227 f"/musehub/ui/testuser/test-beats/listen/{ref}/{track}"
6228 )
6229 assert response.status_code == 200
6230 body = response.text
6231 assert "TRACK_PATH" in body
6232 assert "lead-guitar.mp3" in body
6233
6234
6235 @pytest.mark.anyio
6236 async def test_listen_page_unknown_repo_404(
6237 client: AsyncClient,
6238 db_session: AsyncSession,
6239 ) -> None:
6240 """GET listen page with nonexistent owner/slug must return 404."""
6241 response = await client.get(
6242 "/musehub/ui/nobody/nonexistent-repo/listen/abc123"
6243 )
6244 assert response.status_code == 404
6245
6246
6247 @pytest.mark.anyio
6248 async def test_listen_page_keyboard_shortcuts_documented(
6249 client: AsyncClient,
6250 db_session: AsyncSession,
6251 ) -> None:
6252 """Listen page must document Space, arrow, and L keyboard shortcuts."""
6253 await _make_repo(db_session)
6254 ref = "cafe0011aabb2233"
6255 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6256 assert response.status_code == 200
6257 body = response.text
6258 # Keyboard hint section must be present
6259 assert "Space" in body or "space" in body.lower()
6260 assert "loop" in body.lower()
6261
6262
6263 # ---------------------------------------------------------------------------
6264 # Compare view
6265 # ---------------------------------------------------------------------------
6266
6267
6268 @pytest.mark.anyio
6269 async def test_compare_page_renders(
6270 client: AsyncClient,
6271 db_session: AsyncSession,
6272 ) -> None:
6273 """GET /musehub/ui/{owner}/{slug}/compare/{base}...{head} returns 200 HTML."""
6274 await _make_repo(db_session)
6275 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6276 assert response.status_code == 200
6277 assert "text/html" in response.headers["content-type"]
6278 body = response.text
6279 assert "Muse Hub" in body
6280 assert "main" in body
6281 assert "feature" in body
6282
6283
6284 @pytest.mark.anyio
6285 async def test_compare_page_no_auth_required(
6286 client: AsyncClient,
6287 db_session: AsyncSession,
6288 ) -> None:
6289 """Compare page is accessible without a JWT token."""
6290 await _make_repo(db_session)
6291 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6292 assert response.status_code == 200
6293
6294
6295 @pytest.mark.anyio
6296 async def test_compare_page_invalid_ref_404(
6297 client: AsyncClient,
6298 db_session: AsyncSession,
6299 ) -> None:
6300 """Compare path without '...' separator returns 404."""
6301 await _make_repo(db_session)
6302 response = await client.get("/musehub/ui/testuser/test-beats/compare/mainfeature")
6303 assert response.status_code == 404
6304
6305
6306 @pytest.mark.anyio
6307 async def test_compare_page_unknown_owner_404(
6308 client: AsyncClient,
6309 ) -> None:
6310 """Unknown owner/slug combination returns 404 on compare page."""
6311 response = await client.get("/musehub/ui/nobody/norepo/compare/main...feature")
6312 assert response.status_code == 404
6313
6314
6315 @pytest.mark.anyio
6316 async def test_compare_page_includes_radar(
6317 client: AsyncClient,
6318 db_session: AsyncSession,
6319 ) -> None:
6320 """Compare page SSR HTML contains all five musical dimension names (replaces JS radar).
6321
6322 The compare page now renders data server-side via a dimension table.
6323 Musical dimensions (Melodic, Harmonic, etc.) must appear in the HTML body
6324 before any client-side JavaScript runs.
6325 """
6326 await _make_repo(db_session)
6327 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6328 assert response.status_code == 200
6329 body = response.text
6330 assert "Melodic" in body
6331 assert "Harmonic" in body
6332
6333
6334 @pytest.mark.anyio
6335 async def test_compare_page_includes_piano_roll(
6336 client: AsyncClient,
6337 db_session: AsyncSession,
6338 ) -> None:
6339 """Compare page SSR HTML contains the dimension table (replaces piano roll JS panel).
6340
6341 The compare page now renders a dimension comparison table server-side.
6342 Both ref names must appear as column headers in the HTML.
6343 """
6344 await _make_repo(db_session)
6345 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6346 assert response.status_code == 200
6347 body = response.text
6348 assert "main" in body
6349 assert "feature" in body
6350 assert "Dimension" in body
6351
6352
6353 @pytest.mark.anyio
6354 async def test_compare_page_includes_emotion_diff(
6355 client: AsyncClient,
6356 db_session: AsyncSession,
6357 ) -> None:
6358 """Compare page SSR HTML contains change delta column (replaces emotion diff JS).
6359
6360 The dimension table includes a Change column showing delta values server-side.
6361 """
6362 await _make_repo(db_session)
6363 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6364 assert response.status_code == 200
6365 body = response.text
6366 assert "Change" in body
6367 assert "%" in body
6368
6369
6370 @pytest.mark.anyio
6371 async def test_compare_page_includes_commit_list(
6372 client: AsyncClient,
6373 db_session: AsyncSession,
6374 ) -> None:
6375 """Compare page SSR HTML contains dimension rows (replaces client-side commit list JS).
6376
6377 All five musical dimensions must appear as data rows in the server-rendered table.
6378 """
6379 await _make_repo(db_session)
6380 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6381 assert response.status_code == 200
6382 body = response.text
6383 assert "Rhythmic" in body
6384 assert "Structural" in body
6385 assert "Dynamic" in body
6386
6387
6388 @pytest.mark.anyio
6389 async def test_compare_page_includes_create_pr_button(
6390 client: AsyncClient,
6391 db_session: AsyncSession,
6392 ) -> None:
6393 """Compare page SSR HTML contains both ref names in the heading (replaces PR button CTA).
6394
6395 The SSR compare page shows the base and head refs in the page header.
6396 """
6397 await _make_repo(db_session)
6398 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6399 assert response.status_code == 200
6400 body = response.text
6401 assert "Compare" in body
6402 assert "main" in body
6403 assert "feature" in body
6404
6405
6406 @pytest.mark.anyio
6407 async def test_compare_json_response(
6408 client: AsyncClient,
6409 db_session: AsyncSession,
6410 ) -> None:
6411 """GET /musehub/ui/{owner}/{slug}/compare/{refs} returns HTML with SSR dimension data.
6412
6413 The compare page is now fully SSR — no JSON format negotiation.
6414 The response is always text/html containing the dimension table.
6415 """
6416 await _make_repo(db_session)
6417 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6418 assert response.status_code == 200
6419 assert "text/html" in response.headers["content-type"]
6420 body = response.text
6421 assert "Melodic" in body
6422 assert "main" in body
6423
6424
6425 # ---------------------------------------------------------------------------
6426 # Issue #208 — Branch list and tag browser tests
6427 # ---------------------------------------------------------------------------
6428
6429
6430 async def _make_repo_with_branches(
6431 db_session: AsyncSession,
6432 ) -> tuple[str, str, str]:
6433 """Seed a repo with two branches (main + feature) and return (repo_id, owner, slug)."""
6434 repo = MusehubRepo(
6435 name="branch-test",
6436 owner="testuser",
6437 slug="branch-test",
6438 visibility="private",
6439 owner_user_id="test-owner",
6440 )
6441 db_session.add(repo)
6442 await db_session.flush()
6443 repo_id = str(repo.repo_id)
6444
6445 main_branch = MusehubBranch(repo_id=repo_id, name="main", head_commit_id="aaa000")
6446 feat_branch = MusehubBranch(repo_id=repo_id, name="feat/jazz-bridge", head_commit_id="bbb111")
6447 db_session.add_all([main_branch, feat_branch])
6448
6449 # Two commits on main, one unique commit on feat/jazz-bridge
6450 now = datetime.now(UTC)
6451 c1 = MusehubCommit(
6452 commit_id="aaa000",
6453 repo_id=repo_id,
6454 branch="main",
6455 parent_ids=[],
6456 message="Initial commit",
6457 author="composer@stori.com",
6458 timestamp=now,
6459 )
6460 c2 = MusehubCommit(
6461 commit_id="aaa001",
6462 repo_id=repo_id,
6463 branch="main",
6464 parent_ids=["aaa000"],
6465 message="Add bridge",
6466 author="composer@stori.com",
6467 timestamp=now,
6468 )
6469 c3 = MusehubCommit(
6470 commit_id="bbb111",
6471 repo_id=repo_id,
6472 branch="feat/jazz-bridge",
6473 parent_ids=["aaa000"],
6474 message="Add jazz chord",
6475 author="composer@stori.com",
6476 timestamp=now,
6477 )
6478 db_session.add_all([c1, c2, c3])
6479 await db_session.commit()
6480 return repo_id, "testuser", "branch-test"
6481
6482
6483 async def _make_repo_with_releases(
6484 db_session: AsyncSession,
6485 ) -> tuple[str, str, str]:
6486 """Seed a repo with namespaced releases used as tags."""
6487 repo = MusehubRepo(
6488 name="tag-test",
6489 owner="testuser",
6490 slug="tag-test",
6491 visibility="private",
6492 owner_user_id="test-owner",
6493 )
6494 db_session.add(repo)
6495 await db_session.flush()
6496 repo_id = str(repo.repo_id)
6497
6498 now = datetime.now(UTC)
6499 releases = [
6500 MusehubRelease(
6501 repo_id=repo_id, tag="emotion:happy", title="Happy vibes", body="",
6502 commit_id="abc001", author="composer", created_at=now, download_urls={},
6503 ),
6504 MusehubRelease(
6505 repo_id=repo_id, tag="genre:jazz", title="Jazz release", body="",
6506 commit_id="abc002", author="composer", created_at=now, download_urls={},
6507 ),
6508 MusehubRelease(
6509 repo_id=repo_id, tag="v1.0", title="Version 1.0", body="",
6510 commit_id="abc003", author="composer", created_at=now, download_urls={},
6511 ),
6512 ]
6513 db_session.add_all(releases)
6514 await db_session.commit()
6515 return repo_id, "testuser", "tag-test"
6516
6517
6518 @pytest.mark.anyio
6519 async def test_branches_page_lists_all(
6520 client: AsyncClient,
6521 db_session: AsyncSession,
6522 ) -> None:
6523 """GET /musehub/ui/{owner}/{slug}/branches returns 200 HTML."""
6524 await _make_repo_with_branches(db_session)
6525 resp = await client.get("/musehub/ui/testuser/branch-test/branches")
6526 assert resp.status_code == 200
6527 assert "text/html" in resp.headers["content-type"]
6528 body = resp.text
6529 assert "Muse Hub" in body
6530 # Page-specific JS identifiers
6531 assert "branch-row" in body or "branches" in body.lower()
6532
6533
6534 @pytest.mark.anyio
6535 async def test_branches_default_marked(
6536 client: AsyncClient,
6537 db_session: AsyncSession,
6538 ) -> None:
6539 """JSON response marks the default branch with isDefault=true."""
6540 await _make_repo_with_branches(db_session)
6541 resp = await client.get(
6542 "/musehub/ui/testuser/branch-test/branches",
6543 headers={"Accept": "application/json"},
6544 )
6545 assert resp.status_code == 200
6546 data = resp.json()
6547 assert "branches" in data
6548 default_branches = [b for b in data["branches"] if b.get("isDefault")]
6549 assert len(default_branches) == 1
6550 assert default_branches[0]["name"] == "main"
6551
6552
6553 @pytest.mark.anyio
6554 async def test_branches_compare_link(
6555 client: AsyncClient,
6556 db_session: AsyncSession,
6557 ) -> None:
6558 """Branches page HTML contains compare link JavaScript."""
6559 await _make_repo_with_branches(db_session)
6560 resp = await client.get("/musehub/ui/testuser/branch-test/branches")
6561 assert resp.status_code == 200
6562 body = resp.text
6563 # The JS template must reference the compare URL pattern
6564 assert "compare" in body.lower()
6565
6566
6567 @pytest.mark.anyio
6568 async def test_branches_new_pr_button(
6569 client: AsyncClient,
6570 db_session: AsyncSession,
6571 ) -> None:
6572 """Branches page HTML contains New Pull Request link JavaScript."""
6573 await _make_repo_with_branches(db_session)
6574 resp = await client.get("/musehub/ui/testuser/branch-test/branches")
6575 assert resp.status_code == 200
6576 body = resp.text
6577 assert "Pull Request" in body
6578
6579
6580 @pytest.mark.anyio
6581 async def test_branches_json_response(
6582 client: AsyncClient,
6583 db_session: AsyncSession,
6584 ) -> None:
6585 """JSON response includes branches with ahead/behind counts and divergence placeholder."""
6586 await _make_repo_with_branches(db_session)
6587 resp = await client.get(
6588 "/musehub/ui/testuser/branch-test/branches?format=json",
6589 )
6590 assert resp.status_code == 200
6591 data = resp.json()
6592 assert "branches" in data
6593 assert "defaultBranch" in data
6594 assert data["defaultBranch"] == "main"
6595
6596 branches_by_name = {b["name"]: b for b in data["branches"]}
6597 assert "main" in branches_by_name
6598 assert "feat/jazz-bridge" in branches_by_name
6599
6600 main = branches_by_name["main"]
6601 assert main["isDefault"] is True
6602 assert main["aheadCount"] == 0
6603 assert main["behindCount"] == 0
6604
6605 feat = branches_by_name["feat/jazz-bridge"]
6606 assert feat["isDefault"] is False
6607 # feat has 1 unique commit (bbb111); main has 2 commits (aaa000, aaa001) not shared with feat
6608 assert feat["aheadCount"] == 1
6609 assert feat["behindCount"] == 2
6610
6611 # Divergence is a placeholder (all None)
6612 div = feat["divergence"]
6613 assert div["melodic"] is None
6614 assert div["harmonic"] is None
6615
6616
6617 @pytest.mark.anyio
6618 async def test_tags_page_lists_all(
6619 client: AsyncClient,
6620 db_session: AsyncSession,
6621 ) -> None:
6622 """GET /musehub/ui/{owner}/{slug}/tags returns 200 HTML."""
6623 await _make_repo_with_releases(db_session)
6624 resp = await client.get("/musehub/ui/testuser/tag-test/tags")
6625 assert resp.status_code == 200
6626 assert "text/html" in resp.headers["content-type"]
6627 body = resp.text
6628 assert "Muse Hub" in body
6629 assert "Tags" in body
6630
6631
6632 @pytest.mark.anyio
6633 async def test_tags_namespace_filter(
6634 client: AsyncClient,
6635 db_session: AsyncSession,
6636 ) -> None:
6637 """Tags page HTML includes namespace filter dropdown JavaScript."""
6638 await _make_repo_with_releases(db_session)
6639 resp = await client.get("/musehub/ui/testuser/tag-test/tags")
6640 assert resp.status_code == 200
6641 body = resp.text
6642 # Namespace filter select element is rendered by JS
6643 assert "ns-filter" in body or "namespace" in body.lower()
6644 # Namespace icons present
6645 assert "&#127768;" in body or "emotion" in body
6646
6647
6648 @pytest.mark.anyio
6649 async def test_tags_json_response(
6650 client: AsyncClient,
6651 db_session: AsyncSession,
6652 ) -> None:
6653 """JSON response returns TagListResponse with namespace grouping."""
6654 await _make_repo_with_releases(db_session)
6655 resp = await client.get(
6656 "/musehub/ui/testuser/tag-test/tags?format=json",
6657 )
6658 assert resp.status_code == 200
6659 data = resp.json()
6660 assert "tags" in data
6661 assert "namespaces" in data
6662
6663 # All three releases become tags
6664 assert len(data["tags"]) == 3
6665
6666 tags_by_name = {t["tag"]: t for t in data["tags"]}
6667 assert "emotion:happy" in tags_by_name
6668 assert "genre:jazz" in tags_by_name
6669 assert "v1.0" in tags_by_name
6670
6671 assert tags_by_name["emotion:happy"]["namespace"] == "emotion"
6672 assert tags_by_name["genre:jazz"]["namespace"] == "genre"
6673 assert tags_by_name["v1.0"]["namespace"] == "version"
6674
6675 # Namespaces are sorted
6676 assert sorted(data["namespaces"]) == data["namespaces"]
6677 assert "emotion" in data["namespaces"]
6678 assert "genre" in data["namespaces"]
6679 assert "version" in data["namespaces"]
6680
6681
6682
6683 # ---------------------------------------------------------------------------
6684 # Arrangement matrix page — # ---------------------------------------------------------------------------
6685
6686
6687 # ---------------------------------------------------------------------------
6688 # Piano roll page tests — # ---------------------------------------------------------------------------
6689
6690
6691 @pytest.mark.anyio
6692 async def test_arrange_page_returns_200(
6693 client: AsyncClient,
6694 db_session: AsyncSession,
6695 ) -> None:
6696 """GET /musehub/ui/{owner}/{slug}/arrange/{ref} returns 200 HTML without a JWT."""
6697 await _make_repo(db_session)
6698 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6699 assert response.status_code == 200
6700 assert "text/html" in response.headers["content-type"]
6701
6702
6703 @pytest.mark.anyio
6704 async def test_piano_roll_page_returns_200(
6705 client: AsyncClient,
6706 db_session: AsyncSession,
6707 ) -> None:
6708 """GET /musehub/ui/{owner}/{slug}/piano-roll/{ref} returns 200 HTML."""
6709 await _make_repo(db_session)
6710 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6711 assert response.status_code == 200
6712 assert "text/html" in response.headers["content-type"]
6713
6714
6715 @pytest.mark.anyio
6716 async def test_arrange_page_no_auth_required(
6717 client: AsyncClient,
6718 db_session: AsyncSession,
6719 ) -> None:
6720 """Arrangement matrix page is accessible without a JWT (auth handled client-side)."""
6721 await _make_repo(db_session)
6722 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6723 assert response.status_code == 200
6724 assert response.status_code != 401
6725
6726
6727 @pytest.mark.anyio
6728 async def test_arrange_page_contains_musehub(
6729 client: AsyncClient,
6730 db_session: AsyncSession,
6731 ) -> None:
6732 """Arrangement matrix page HTML shell contains 'Muse Hub' branding."""
6733 await _make_repo(db_session)
6734 response = await client.get("/musehub/ui/testuser/test-beats/arrange/abc1234")
6735 assert response.status_code == 200
6736 assert "Muse Hub" in response.text
6737
6738
6739 @pytest.mark.anyio
6740 async def test_arrange_page_contains_grid_js(
6741 client: AsyncClient,
6742 db_session: AsyncSession,
6743 ) -> None:
6744 """Arrangement matrix page embeds the grid rendering JS (renderMatrix or arrange)."""
6745 await _make_repo(db_session)
6746 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6747 assert response.status_code == 200
6748 body = response.text
6749 assert "renderMatrix" in body or "arrange" in body.lower()
6750
6751
6752 @pytest.mark.anyio
6753 async def test_arrange_page_contains_density_logic(
6754 client: AsyncClient,
6755 db_session: AsyncSession,
6756 ) -> None:
6757 """Arrangement matrix page includes density colour logic."""
6758 await _make_repo(db_session)
6759 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6760 assert response.status_code == 200
6761 body = response.text
6762 assert "density" in body.lower() or "noteDensity" in body
6763
6764
6765 @pytest.mark.anyio
6766 async def test_arrange_page_contains_token_form(
6767 client: AsyncClient,
6768 db_session: AsyncSession,
6769 ) -> None:
6770 """Arrangement matrix page includes the JWT token form for client-side auth."""
6771 await _make_repo(db_session)
6772 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6773 assert response.status_code == 200
6774 body = response.text
6775 assert 'id="token-form"' in body
6776 assert "musehub.js" in body
6777
6778
6779 @pytest.mark.anyio
6780 async def test_arrange_page_unknown_repo_returns_404(
6781 client: AsyncClient,
6782 db_session: AsyncSession,
6783 ) -> None:
6784 """GET /musehub/ui/{unknown}/{slug}/arrange/{ref} returns 404 for unknown repos."""
6785 response = await client.get("/musehub/ui/unknown-user/no-such-repo/arrange/HEAD")
6786 assert response.status_code == 404
6787
6788
6789 @pytest.mark.anyio
6790 async def test_commit_detail_unknown_format_param_returns_html(
6791 client: AsyncClient,
6792 db_session: AsyncSession,
6793 ) -> None:
6794 """GET commit detail page ignores ?format=json — SSR always returns HTML."""
6795 await _seed_commit_detail_fixtures(db_session)
6796 sha = "bbbb1111222233334444555566667777bbbbcccc"
6797 response = await client.get(
6798 f"/musehub/ui/testuser/commit-detail-test/commits/{sha}?format=json"
6799 )
6800 assert response.status_code == 200
6801 assert "text/html" in response.headers["content-type"]
6802 # SSR commit page — commit message appears in body
6803 assert "feat(keys)" in response.text
6804
6805
6806 @pytest.mark.anyio
6807 async def test_commit_detail_wavesurfer_js_conditional_on_audio_url(
6808 client: AsyncClient,
6809 db_session: AsyncSession,
6810 ) -> None:
6811 """WaveSurfer JS block is only present when audio_url is set (replaces musicalMeta JS checks)."""
6812 await _seed_commit_detail_fixtures(db_session)
6813 sha = "bbbb1111222233334444555566667777bbbbcccc"
6814 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
6815 assert response.status_code == 200
6816 body = response.text
6817 # The child commit has no snapshot_id in _seed_commit_detail_fixtures → no WaveSurfer
6818 assert "commit-waveform" not in body
6819 # WaveSurfer script only loaded when audio is present — not here
6820 assert "wavesurfer.min.js" not in body
6821
6822
6823 @pytest.mark.anyio
6824 async def test_commit_detail_nav_has_parent_link(
6825 client: AsyncClient,
6826 db_session: AsyncSession,
6827 ) -> None:
6828 """Commit detail page navigation includes the parent commit link (SSR)."""
6829 await _seed_commit_detail_fixtures(db_session)
6830 sha = "bbbb1111222233334444555566667777bbbbcccc"
6831 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
6832 assert response.status_code == 200
6833 body = response.text
6834 # SSR renders parent commit link when parent_ids is non-empty
6835 assert "Parent Commit" in body
6836 # Parent SHA abbreviated to 8 chars in href
6837 assert "aaaa0000" in body
6838
6839
6840 @pytest.mark.anyio
6841 async def test_piano_roll_page_no_auth_required(
6842 client: AsyncClient,
6843 db_session: AsyncSession,
6844 ) -> None:
6845 """Piano roll UI page is accessible without a JWT token."""
6846 await _make_repo(db_session)
6847 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6848 assert response.status_code == 200
6849
6850
6851 @pytest.mark.anyio
6852 async def test_piano_roll_page_loads_piano_roll_js(
6853 client: AsyncClient,
6854 db_session: AsyncSession,
6855 ) -> None:
6856 """Piano roll page references piano-roll.js script."""
6857 await _make_repo(db_session)
6858 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6859 assert response.status_code == 200
6860 assert "piano-roll.js" in response.text
6861
6862
6863 @pytest.mark.anyio
6864 async def test_piano_roll_page_contains_canvas(
6865 client: AsyncClient,
6866 db_session: AsyncSession,
6867 ) -> None:
6868 """Piano roll page embeds a canvas element for rendering."""
6869 await _make_repo(db_session)
6870 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6871 assert response.status_code == 200
6872 body = response.text
6873 assert "PianoRoll" in body or "piano-canvas" in body or "piano-roll.js" in body
6874
6875
6876 @pytest.mark.anyio
6877 async def test_piano_roll_page_has_token_form(
6878 client: AsyncClient,
6879 db_session: AsyncSession,
6880 ) -> None:
6881 """Piano roll page includes the JWT token form for unauthenticated visitors."""
6882 await _make_repo(db_session)
6883 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6884 assert response.status_code == 200
6885 assert 'id="token-form"' in response.text
6886 assert "musehub.js" in response.text
6887
6888
6889 @pytest.mark.anyio
6890 async def test_piano_roll_page_unknown_repo_404(
6891 client: AsyncClient,
6892 db_session: AsyncSession,
6893 ) -> None:
6894 """Piano roll page for an unknown repo returns 404."""
6895 response = await client.get("/musehub/ui/nobody/no-repo/piano-roll/main")
6896 assert response.status_code == 404
6897
6898
6899 @pytest.mark.anyio
6900 async def test_arrange_tab_in_repo_nav(
6901 client: AsyncClient,
6902 db_session: AsyncSession,
6903 ) -> None:
6904 """Repo home page navigation includes an 'Arrange' tab link."""
6905 await _make_repo(db_session)
6906 response = await client.get("/musehub/ui/testuser/test-beats")
6907 assert response.status_code == 200
6908 assert "Arrange" in response.text or "arrange" in response.text
6909
6910
6911 @pytest.mark.anyio
6912 async def test_piano_roll_track_page_returns_200(
6913 client: AsyncClient,
6914 db_session: AsyncSession,
6915 ) -> None:
6916 """GET /piano-roll/{ref}/{path} (single track) returns 200."""
6917 await _make_repo(db_session)
6918 response = await client.get(
6919 "/musehub/ui/testuser/test-beats/piano-roll/main/tracks/bass.mid"
6920 )
6921 assert response.status_code == 200
6922
6923
6924 @pytest.mark.anyio
6925 async def test_piano_roll_track_page_embeds_path(
6926 client: AsyncClient,
6927 db_session: AsyncSession,
6928 ) -> None:
6929 """Single-track piano roll page embeds the MIDI file path in the JS context."""
6930 await _make_repo(db_session)
6931 response = await client.get(
6932 "/musehub/ui/testuser/test-beats/piano-roll/main/tracks/bass.mid"
6933 )
6934 assert response.status_code == 200
6935 assert "tracks/bass.mid" in response.text
6936
6937
6938 @pytest.mark.anyio
6939 async def test_piano_roll_js_served(client: AsyncClient) -> None:
6940 """GET /musehub/static/piano-roll.js returns 200 JavaScript."""
6941 response = await client.get("/musehub/static/piano-roll.js")
6942 assert response.status_code == 200
6943 assert "javascript" in response.headers.get("content-type", "")
6944
6945
6946 @pytest.mark.anyio
6947 async def test_piano_roll_js_contains_renderer(client: AsyncClient) -> None:
6948 """piano-roll.js exports the PianoRoll.render function."""
6949 response = await client.get("/musehub/static/piano-roll.js")
6950 assert response.status_code == 200
6951 body = response.text
6952 assert "PianoRoll" in body
6953 assert "render" in body
6954
6955
6956
6957 async def _seed_blob_fixtures(db_session: AsyncSession) -> str:
6958 """Seed a public repo with a branch and typed objects for blob viewer tests.
6959
6960 Creates:
6961 - repo: testuser/blob-test (public)
6962 - branch: main
6963 - objects: tracks/bass.mid, tracks/keys.mp3, metadata.json, cover.webp
6964
6965 Returns repo_id.
6966 """
6967 repo = MusehubRepo(
6968 name="blob-test",
6969 owner="testuser",
6970 slug="blob-test",
6971 visibility="public",
6972 owner_user_id="test-owner",
6973 )
6974 db_session.add(repo)
6975 await db_session.flush()
6976
6977 commit = MusehubCommit(
6978 commit_id="blobdeadbeef12",
6979 repo_id=str(repo.repo_id),
6980 message="add blob fixtures",
6981 branch="main",
6982 author="testuser",
6983 timestamp=datetime.now(tz=UTC),
6984 )
6985 db_session.add(commit)
6986
6987 branch = MusehubBranch(
6988 repo_id=str(repo.repo_id),
6989 name="main",
6990 head_commit_id="blobdeadbeef12",
6991 )
6992 db_session.add(branch)
6993
6994 for path, size in [
6995 ("tracks/bass.mid", 2048),
6996 ("tracks/keys.mp3", 8192),
6997 ("metadata.json", 512),
6998 ("cover.webp", 4096),
6999 ]:
7000 obj = MusehubObject(
7001 object_id=f"sha256:blob_{path.replace('/', '_')}",
7002 repo_id=str(repo.repo_id),
7003 path=path,
7004 size_bytes=size,
7005 disk_path=f"/tmp/blob_{path.replace('/', '_')}",
7006 )
7007 db_session.add(obj)
7008
7009 await db_session.commit()
7010 return str(repo.repo_id)
7011
7012
7013
7014 @pytest.mark.anyio
7015 async def test_blob_404_unknown_path(
7016 client: AsyncClient,
7017 db_session: AsyncSession,
7018 ) -> None:
7019 """GET /api/v1/musehub/repos/{repo_id}/blob/{ref}/{path} returns 404 for unknown path."""
7020 repo_id = await _seed_blob_fixtures(db_session)
7021 response = await client.get(f"/api/v1/musehub/repos/{repo_id}/blob/main/does/not/exist.mid")
7022 assert response.status_code == 404
7023
7024
7025 @pytest.mark.anyio
7026 async def test_blob_image_shows_inline(
7027 client: AsyncClient,
7028 db_session: AsyncSession,
7029 ) -> None:
7030 """Blob page for .webp file includes <img> rendering logic in the template JS."""
7031 await _seed_blob_fixtures(db_session)
7032 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/cover.webp")
7033 assert response.status_code == 200
7034 body = response.text
7035 # JS template emits <img> for image file type
7036 assert "<img" in body or "blob-img" in body
7037 assert "cover.webp" in body
7038
7039
7040 @pytest.mark.anyio
7041 async def test_blob_json_response(
7042 client: AsyncClient,
7043 db_session: AsyncSession,
7044 ) -> None:
7045 """GET /api/v1/musehub/repos/{repo_id}/blob/{ref}/{path} returns BlobMetaResponse JSON."""
7046 repo_id = await _seed_blob_fixtures(db_session)
7047 response = await client.get(
7048 f"/api/v1/musehub/repos/{repo_id}/blob/main/tracks/bass.mid"
7049 )
7050 assert response.status_code == 200
7051 data = response.json()
7052 assert data["path"] == "tracks/bass.mid"
7053 assert data["filename"] == "bass.mid"
7054 assert data["sizeBytes"] == 2048
7055 assert data["fileType"] == "midi"
7056 assert data["sha"].startswith("sha256:")
7057 assert "/raw/" in data["rawUrl"]
7058 # MIDI is binary — no content_text
7059 assert data["contentText"] is None
7060 @pytest.mark.anyio
7061 async def test_blob_json_syntax_highlighted(
7062 client: AsyncClient,
7063 db_session: AsyncSession,
7064 ) -> None:
7065 """Blob page for .json file includes syntax-highlighting logic in the template JS."""
7066 await _seed_blob_fixtures(db_session)
7067 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/metadata.json")
7068 assert response.status_code == 200
7069 body = response.text
7070 # highlightJson function must be present in the template script
7071 assert "highlightJson" in body or "json-key" in body
7072 assert "metadata.json" in body
7073
7074
7075 @pytest.mark.anyio
7076 async def test_blob_midi_shows_piano_roll_link(
7077 client: AsyncClient,
7078 db_session: AsyncSession,
7079 ) -> None:
7080 """GET /musehub/ui/{owner}/{repo}/blob/{ref}/{path} returns 200 HTML for a .mid file.
7081
7082 The template's client-side JS must reference the piano roll URL pattern so that
7083 clicking the page in a browser navigates to the piano roll viewer.
7084 """
7085 await _seed_blob_fixtures(db_session)
7086 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/tracks/bass.mid")
7087 assert response.status_code == 200
7088 assert "text/html" in response.headers["content-type"]
7089 body = response.text
7090 # JS in the template constructs piano-roll URLs for MIDI files
7091 assert "piano-roll" in body or "Piano Roll" in body
7092 # Filename is embedded in the page context
7093 assert "bass.mid" in body
7094
7095
7096 @pytest.mark.anyio
7097 async def test_blob_mp3_shows_audio_player(
7098 client: AsyncClient,
7099 db_session: AsyncSession,
7100 ) -> None:
7101 """Blob page for .mp3 file includes <audio> rendering logic in the template JS."""
7102 await _seed_blob_fixtures(db_session)
7103 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/tracks/keys.mp3")
7104 assert response.status_code == 200
7105 body = response.text
7106 # JS template emits <audio> element for audio file type
7107 assert "<audio" in body or "blob-audio" in body
7108 assert "keys.mp3" in body
7109
7110
7111 @pytest.mark.anyio
7112 async def test_blob_raw_button(
7113 client: AsyncClient,
7114 db_session: AsyncSession,
7115 ) -> None:
7116 """Blob page JS constructs a Raw download link via the /raw/ endpoint."""
7117 await _seed_blob_fixtures(db_session)
7118 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/tracks/bass.mid")
7119 assert response.status_code == 200
7120 body = response.text
7121 # JS constructs raw URL — the string '/raw/' must appear in the template script
7122 assert "/raw/" in body
7123
7124
7125 @pytest.mark.anyio
7126 async def test_score_page_contains_legend(
7127 client: AsyncClient,
7128 db_session: AsyncSession,
7129 ) -> None:
7130 """Score page includes a legend for note symbols."""
7131 await _make_repo(db_session)
7132 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7133 assert response.status_code == 200
7134 body = response.text
7135 assert "legend" in body or "Note" in body
7136
7137
7138 @pytest.mark.anyio
7139 async def test_score_page_contains_score_meta(
7140 client: AsyncClient,
7141 db_session: AsyncSession,
7142 ) -> None:
7143 """Score page embeds a score metadata panel (key/tempo/time signature)."""
7144 await _make_repo(db_session)
7145 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7146 assert response.status_code == 200
7147 body = response.text
7148 assert "score-meta" in body
7149
7150
7151 @pytest.mark.anyio
7152 async def test_score_page_contains_staff_container(
7153 client: AsyncClient,
7154 db_session: AsyncSession,
7155 ) -> None:
7156 """Score page embeds the SVG staff container markup."""
7157 await _make_repo(db_session)
7158 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7159 assert response.status_code == 200
7160 body = response.text
7161 assert "staff-container" in body or "staves" in body
7162
7163
7164 @pytest.mark.anyio
7165 async def test_score_page_contains_track_selector(
7166 client: AsyncClient,
7167 db_session: AsyncSession,
7168 ) -> None:
7169 """Score page embeds a track selector element."""
7170 await _make_repo(db_session)
7171 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7172 assert response.status_code == 200
7173 body = response.text
7174 assert "track-selector" in body
7175
7176
7177 @pytest.mark.anyio
7178 async def test_score_page_no_auth_required(
7179 client: AsyncClient,
7180 db_session: AsyncSession,
7181 ) -> None:
7182 """Score UI page must be accessible without an Authorization header."""
7183 await _make_repo(db_session)
7184 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7185 assert response.status_code == 200
7186 assert response.status_code != 401
7187
7188
7189 @pytest.mark.anyio
7190 async def test_score_page_renders(
7191 client: AsyncClient,
7192 db_session: AsyncSession,
7193 ) -> None:
7194 """GET /musehub/ui/{owner}/{slug}/score/{ref} returns 200 HTML."""
7195 await _make_repo(db_session)
7196 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7197 assert response.status_code == 200
7198 assert "text/html" in response.headers["content-type"]
7199 body = response.text
7200 assert "Muse Hub" in body
7201
7202
7203 @pytest.mark.anyio
7204 async def test_score_part_page_includes_path(
7205 client: AsyncClient,
7206 db_session: AsyncSession,
7207 ) -> None:
7208 """Single-part score page injects the path segment into page data."""
7209 await _make_repo(db_session)
7210 response = await client.get("/musehub/ui/testuser/test-beats/score/main/piano")
7211 assert response.status_code == 200
7212 body = response.text
7213 # scorePath JS variable should be set to the path segment
7214 assert "piano" in body
7215
7216
7217 @pytest.mark.anyio
7218 async def test_score_part_page_renders(
7219 client: AsyncClient,
7220 db_session: AsyncSession,
7221 ) -> None:
7222 """GET /musehub/ui/{owner}/{slug}/score/{ref}/{path} returns 200 HTML."""
7223 await _make_repo(db_session)
7224 response = await client.get("/musehub/ui/testuser/test-beats/score/main/piano")
7225 assert response.status_code == 200
7226 assert "text/html" in response.headers["content-type"]
7227 body = response.text
7228 assert "Muse Hub" in body
7229
7230
7231 @pytest.mark.anyio
7232 async def test_score_unknown_repo_404(
7233 client: AsyncClient,
7234 db_session: AsyncSession,
7235 ) -> None:
7236 """GET /musehub/ui/{unknown}/{slug}/score/{ref} returns 404."""
7237 response = await client.get("/musehub/ui/nobody/no-beats/score/main")
7238 assert response.status_code == 404
7239
7240
7241 # ---------------------------------------------------------------------------
7242 # Arrangement matrix page — # ---------------------------------------------------------------------------
7243
7244
7245 # ---------------------------------------------------------------------------
7246 # Piano roll page tests — # ---------------------------------------------------------------------------
7247
7248
7249 @pytest.mark.anyio
7250 async def test_ui_commit_page_artifact_auth_uses_blob_proxy(
7251 client: AsyncClient,
7252 db_session: AsyncSession,
7253 ) -> None:
7254 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7255
7256 The pre-SSR blob-proxy artifact pattern no longer applies — artifacts are loaded
7257 via the API. Non-existent commit SHAs now return 404 rather than an empty JS shell.
7258 """
7259 await _make_repo(db_session)
7260 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7261 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
7262 assert response.status_code == 404
7263
7264
7265 # ---------------------------------------------------------------------------
7266 # Reaction bars — # ---------------------------------------------------------------------------
7267
7268
7269 @pytest.mark.anyio
7270 async def test_reaction_bar_js_in_musehub_js(
7271 client: AsyncClient,
7272 db_session: AsyncSession,
7273 ) -> None:
7274 """musehub.js must define loadReactions and toggleReaction for all detail pages."""
7275 response = await client.get("/musehub/static/musehub.js")
7276 assert response.status_code == 200
7277 body = response.text
7278 assert "loadReactions" in body
7279 assert "toggleReaction" in body
7280 assert "REACTION_BAR_EMOJIS" in body
7281
7282
7283 @pytest.mark.anyio
7284 async def test_reaction_bar_emojis_in_musehub_js(
7285 client: AsyncClient,
7286 db_session: AsyncSession,
7287 ) -> None:
7288 """musehub.js reaction bar must include all 8 required emojis."""
7289 response = await client.get("/musehub/static/musehub.js")
7290 assert response.status_code == 200
7291 body = response.text
7292 for emoji in ["🔥", "❤️", "👏", "✨", "🎵", "🎸", "🎹", "🥁"]:
7293 assert emoji in body, f"Emoji {emoji!r} missing from musehub.js"
7294
7295
7296 @pytest.mark.anyio
7297 async def test_reaction_bar_commit_page_has_load_call(
7298 client: AsyncClient,
7299 db_session: AsyncSession,
7300 ) -> None:
7301 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7302
7303 Reactions are loaded via the API; the reaction bar is no longer a JS-only element
7304 in the SSR commit_detail.html template. Non-existent commits return 404.
7305 """
7306 await _make_repo(db_session)
7307 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7308 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
7309 assert response.status_code == 404
7310
7311
7312 @pytest.mark.anyio
7313 async def test_reaction_bar_pr_detail_has_load_call(
7314 client: AsyncClient,
7315 db_session: AsyncSession,
7316 ) -> None:
7317 """PR detail page must call loadReactions for target_type 'pull_request'."""
7318 await _make_repo(db_session)
7319 pr_id = "some-pr-uuid-1234"
7320 response = await client.get(f"/musehub/ui/testuser/test-beats/pulls/{pr_id}")
7321 assert response.status_code == 200
7322 body = response.text
7323 assert "loadReactions" in body
7324 assert "pr-reactions" in body
7325
7326
7327 @pytest.mark.anyio
7328 async def test_reaction_bar_issue_detail_has_load_call(
7329 client: AsyncClient,
7330 db_session: AsyncSession,
7331 ) -> None:
7332 """Issue detail page must call loadReactions for target_type 'issue'."""
7333 await _make_repo(db_session)
7334 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
7335 assert response.status_code == 200
7336 body = response.text
7337 assert "loadReactions" in body
7338 assert "issue-reactions" in body
7339
7340
7341 @pytest.mark.anyio
7342 async def test_reaction_bar_release_detail_has_load_call(
7343 client: AsyncClient,
7344 db_session: AsyncSession,
7345 ) -> None:
7346 """Release detail page must call loadReactions for target_type 'release'."""
7347 await _make_repo(db_session)
7348 response = await client.get("/musehub/ui/testuser/test-beats/releases/v1.0")
7349 assert response.status_code == 200
7350 body = response.text
7351 assert "loadReactions" in body
7352 assert "release-reactions" in body
7353
7354
7355 @pytest.mark.anyio
7356 async def test_reaction_bar_session_detail_has_load_call(
7357 client: AsyncClient,
7358 db_session: AsyncSession,
7359 ) -> None:
7360 """Session detail page must call loadReactions for target_type 'session'."""
7361 await _make_repo(db_session)
7362 session_id = "some-session-uuid-1234"
7363 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
7364 assert response.status_code == 200
7365 body = response.text
7366 assert "loadReactions" in body
7367 assert "session-reactions" in body
7368
7369
7370 @pytest.mark.anyio
7371 async def test_reaction_api_allows_new_emojis(
7372 client: AsyncClient,
7373 db_session: AsyncSession,
7374 ) -> None:
7375 """POST /reactions with 👏 and 🎹 (new emojis) must be accepted (not 400)."""
7376 from musehub.db.musehub_models import MusehubRepo
7377 repo = MusehubRepo(
7378 name="reaction-test",
7379 owner="testuser",
7380 slug="reaction-test",
7381 visibility="public",
7382 owner_user_id="reaction-owner",
7383 )
7384 db_session.add(repo)
7385 await db_session.commit()
7386 await db_session.refresh(repo)
7387 repo_id = str(repo.repo_id)
7388
7389 token_headers = {"Authorization": "Bearer test-token"}
7390
7391 for emoji in ["👏", "🎹"]:
7392 response = await client.post(
7393 f"/api/v1/musehub/repos/{repo_id}/reactions",
7394 json={"target_type": "commit", "target_id": "abc123", "emoji": emoji},
7395 headers=token_headers,
7396 )
7397 assert response.status_code not in (400, 422), (
7398 f"Emoji {emoji!r} rejected by API: {response.status_code} {response.text}"
7399 )
7400
7401
7402 @pytest.mark.anyio
7403 async def test_reaction_api_allows_release_and_session_target_types(
7404 client: AsyncClient,
7405 db_session: AsyncSession,
7406 ) -> None:
7407 """POST /reactions must accept 'release' and 'session' as target_type values.
7408
7409 These target types were added to support reaction bars on
7410 release_detail and session_detail pages.
7411 """
7412 from musehub.db.musehub_models import MusehubRepo
7413 repo = MusehubRepo(
7414 name="target-type-test",
7415 owner="testuser",
7416 slug="target-type-test",
7417 visibility="public",
7418 owner_user_id="target-type-owner",
7419 )
7420 db_session.add(repo)
7421 await db_session.commit()
7422 await db_session.refresh(repo)
7423 repo_id = str(repo.repo_id)
7424
7425 token_headers = {"Authorization": "Bearer test-token"}
7426
7427 for target_type in ["release", "session"]:
7428 response = await client.post(
7429 f"/api/v1/musehub/repos/{repo_id}/reactions",
7430 json={"target_type": target_type, "target_id": "some-id", "emoji": "🔥"},
7431 headers=token_headers,
7432 )
7433 assert response.status_code not in (400, 422), (
7434 f"target_type {target_type!r} rejected: {response.status_code} {response.text}"
7435 )
7436
7437
7438 @pytest.mark.anyio
7439 async def test_reaction_bar_css_loaded_on_detail_pages(
7440 client: AsyncClient,
7441 db_session: AsyncSession,
7442 ) -> None:
7443 """Detail pages must load components.css which contains .reaction-bar styles."""
7444 await _make_repo(db_session)
7445 pages = [
7446 "/musehub/ui/testuser/test-beats/pulls/pr-uuid-abc",
7447 "/musehub/ui/testuser/test-beats/issues/1",
7448 "/musehub/ui/testuser/test-beats/releases/v1.0",
7449 "/musehub/ui/testuser/test-beats/sessions/session-uuid-abc",
7450 ]
7451 for page in pages:
7452 response = await client.get(page)
7453 assert response.status_code == 200
7454 assert "components.css" in response.text, f"components.css missing from {page}"
7455
7456
7457 @pytest.mark.anyio
7458 async def test_reaction_bar_components_css_has_styles(
7459 client: AsyncClient,
7460 db_session: AsyncSession,
7461 ) -> None:
7462 """components.css must define .reaction-bar and .reaction-btn CSS classes."""
7463 response = await client.get("/musehub/static/components.css")
7464 assert response.status_code == 200
7465 body = response.text
7466 assert ".reaction-bar" in body
7467 assert ".reaction-btn" in body
7468 assert ".reaction-btn--active" in body
7469 assert ".reaction-count" in body
7470
7471
7472 # ---------------------------------------------------------------------------
7473 # Feed page tests — (rich event cards)
7474 # ---------------------------------------------------------------------------
7475
7476
7477 @pytest.mark.anyio
7478 async def test_feed_page_returns_200(
7479 client: AsyncClient,
7480 db_session: AsyncSession,
7481 ) -> None:
7482 """GET /musehub/ui/feed returns 200 HTML without requiring a JWT."""
7483 response = await client.get("/musehub/ui/feed")
7484 assert response.status_code == 200
7485 assert "text/html" in response.headers["content-type"]
7486 assert "Activity Feed" in response.text
7487
7488
7489 @pytest.mark.anyio
7490 async def test_feed_page_no_raw_json_payload(
7491 client: AsyncClient,
7492 db_session: AsyncSession,
7493 ) -> None:
7494 """Feed page must not render raw JSON.stringify of notification payload.
7495
7496 Regression guard: the old implementation called
7497 JSON.stringify(item.payload) directly into the DOM, exposing raw JSON
7498 to users. The new rich card templates must not do this.
7499 """
7500 response = await client.get("/musehub/ui/feed")
7501 assert response.status_code == 200
7502 body = response.text
7503 assert "JSON.stringify(item.payload" not in body
7504 assert "JSON.stringify(item" not in body
7505
7506
7507 @pytest.mark.anyio
7508 async def test_feed_page_has_event_meta_for_all_types(
7509 client: AsyncClient,
7510 db_session: AsyncSession,
7511 ) -> None:
7512 """Feed page must define EVENT_META entries for all 8 notification event types."""
7513 response = await client.get("/musehub/ui/feed")
7514 assert response.status_code == 200
7515 body = response.text
7516 for event_type in (
7517 "comment",
7518 "mention",
7519 "pr_opened",
7520 "pr_merged",
7521 "issue_opened",
7522 "issue_closed",
7523 "new_commit",
7524 "new_follower",
7525 ):
7526 assert event_type in body, f"EVENT_META missing entry for '{event_type}'"
7527
7528
7529 @pytest.mark.anyio
7530 async def test_feed_page_has_data_notif_id_attribute(
7531 client: AsyncClient,
7532 db_session: AsyncSession,
7533 ) -> None:
7534 """Each event card must carry a data-notif-id attribute.
7535
7536 This attribute is the hook that (mark-as-read UX) will use to
7537 attach action buttons to each card without restructuring the DOM.
7538 """
7539 response = await client.get("/musehub/ui/feed")
7540 assert response.status_code == 200
7541 assert "data-notif-id" in response.text
7542
7543
7544 @pytest.mark.anyio
7545 async def test_feed_page_has_unread_indicator(
7546 client: AsyncClient,
7547 db_session: AsyncSession,
7548 ) -> None:
7549 """Feed page must include logic to highlight unread cards with a left border."""
7550 response = await client.get("/musehub/ui/feed")
7551 assert response.status_code == 200
7552 body = response.text
7553 assert "is_read" in body
7554 assert "color-accent" in body
7555
7556
7557 @pytest.mark.anyio
7558 async def test_feed_page_has_actor_avatar_logic(
7559 client: AsyncClient,
7560 db_session: AsyncSession,
7561 ) -> None:
7562 """Feed page must render actor avatars using the actorHsl / actorAvatar helpers."""
7563 response = await client.get("/musehub/ui/feed")
7564 assert response.status_code == 200
7565 body = response.text
7566 assert "actorHsl" in body
7567 assert "actorAvatar" in body
7568
7569
7570 @pytest.mark.anyio
7571 async def test_feed_page_has_relative_timestamp(
7572 client: AsyncClient,
7573 db_session: AsyncSession,
7574 ) -> None:
7575 """Feed page must call fmtRelative to render timestamps in a human-readable form."""
7576 response = await client.get("/musehub/ui/feed")
7577 assert response.status_code == 200
7578 assert "fmtRelative" in response.text
7579
7580
7581 # ---------------------------------------------------------------------------
7582 # Mark-as-read UX tests — # ---------------------------------------------------------------------------
7583
7584
7585 @pytest.mark.anyio
7586 async def test_feed_page_has_mark_one_read_function(
7587 client: AsyncClient,
7588 db_session: AsyncSession,
7589 ) -> None:
7590 """Feed page must define markOneRead() for per-notification mark-as-read."""
7591 response = await client.get("/musehub/ui/feed")
7592 assert response.status_code == 200
7593 assert "markOneRead" in response.text
7594
7595
7596 @pytest.mark.anyio
7597 async def test_feed_page_has_mark_all_read_function(
7598 client: AsyncClient,
7599 db_session: AsyncSession,
7600 ) -> None:
7601 """Feed page must define markAllRead() for bulk mark-as-read."""
7602 response = await client.get("/musehub/ui/feed")
7603 assert response.status_code == 200
7604 assert "markAllRead" in response.text
7605
7606
7607 @pytest.mark.anyio
7608 async def test_feed_page_has_decrement_nav_badge_function(
7609 client: AsyncClient,
7610 db_session: AsyncSession,
7611 ) -> None:
7612 """Feed page must define decrementNavBadge() to keep the nav badge in sync."""
7613 response = await client.get("/musehub/ui/feed")
7614 assert response.status_code == 200
7615 assert "decrementNavBadge" in response.text
7616
7617
7618 @pytest.mark.anyio
7619 async def test_feed_page_mark_read_btn_targets_notification_endpoint(
7620 client: AsyncClient,
7621 db_session: AsyncSession,
7622 ) -> None:
7623 """markOneRead() must call POST /notifications/{notif_id}/read."""
7624 response = await client.get("/musehub/ui/feed")
7625 assert response.status_code == 200
7626 body = response.text
7627 assert "/notifications/" in body
7628 assert "mark-read-btn" in body
7629
7630
7631 @pytest.mark.anyio
7632 async def test_feed_page_mark_all_btn_targets_read_all_endpoint(
7633 client: AsyncClient,
7634 db_session: AsyncSession,
7635 ) -> None:
7636 """markAllRead() must call POST /notifications/read-all."""
7637 response = await client.get("/musehub/ui/feed")
7638 assert response.status_code == 200
7639 assert "read-all" in response.text
7640
7641
7642 @pytest.mark.anyio
7643 async def test_feed_page_mark_all_btn_present_in_template(
7644 client: AsyncClient,
7645 db_session: AsyncSession,
7646 ) -> None:
7647 """Feed page must render a 'Mark all as read' button element."""
7648 response = await client.get("/musehub/ui/feed")
7649 assert response.status_code == 200
7650 assert "mark-all-read-btn" in response.text
7651
7652
7653 @pytest.mark.anyio
7654 async def test_feed_page_mark_read_updates_nav_badge(
7655 client: AsyncClient,
7656 db_session: AsyncSession,
7657 ) -> None:
7658 """After marking all as read, page logic must update nav-notif-badge to hidden."""
7659 response = await client.get("/musehub/ui/feed")
7660 assert response.status_code == 200
7661 body = response.text
7662 assert "nav-notif-badge" in body
7663 assert "decrementNavBadge" in body
7664
7665
7666 # ---------------------------------------------------------------------------
7667 # Per-dimension analysis detail pages
7668 # ---------------------------------------------------------------------------
7669
7670
7671 @pytest.mark.anyio
7672 async def test_key_analysis_page_renders(
7673 client: AsyncClient,
7674 db_session: AsyncSession,
7675 ) -> None:
7676 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/key returns 200 HTML."""
7677 await _make_repo(db_session)
7678 ref = "abc1234567890abcdef"
7679 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/key")
7680 assert response.status_code == 200
7681 assert "text/html" in response.headers["content-type"]
7682
7683
7684 @pytest.mark.anyio
7685 async def test_key_analysis_page_no_auth_required(
7686 client: AsyncClient,
7687 db_session: AsyncSession,
7688 ) -> None:
7689 """Key analysis page must be accessible without a JWT (HTML shell handles auth)."""
7690 await _make_repo(db_session)
7691 ref = "deadbeef1234"
7692 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/key")
7693 assert response.status_code != 401
7694 assert response.status_code == 200
7695
7696
7697 @pytest.mark.anyio
7698 async def test_key_analysis_page_contains_key_data_labels(
7699 client: AsyncClient,
7700 db_session: AsyncSession,
7701 ) -> None:
7702 """Key page must contain tonic, mode, relative key, and confidence UI elements."""
7703 await _make_repo(db_session)
7704 ref = "cafebabe12345678"
7705 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/key")
7706 assert response.status_code == 200
7707 body = response.text
7708 assert "Key Detection" in body
7709 assert "Relative Key" in body
7710 assert "Detection Confidence" in body
7711 assert "Alternate Key" in body
7712
7713
7714 @pytest.mark.anyio
7715 async def test_meter_analysis_page_renders(
7716 client: AsyncClient,
7717 db_session: AsyncSession,
7718 ) -> None:
7719 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/meter returns 200 HTML."""
7720 await _make_repo(db_session)
7721 ref = "abc1234567890abcdef"
7722 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/meter")
7723 assert response.status_code == 200
7724 assert "text/html" in response.headers["content-type"]
7725
7726
7727 @pytest.mark.anyio
7728 async def test_meter_analysis_page_no_auth_required(
7729 client: AsyncClient,
7730 db_session: AsyncSession,
7731 ) -> None:
7732 """Meter analysis page must be accessible without a JWT (HTML shell handles auth)."""
7733 await _make_repo(db_session)
7734 ref = "deadbeef5678"
7735 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/meter")
7736 assert response.status_code != 401
7737 assert response.status_code == 200
7738
7739
7740 @pytest.mark.anyio
7741 async def test_meter_analysis_page_contains_meter_data_labels(
7742 client: AsyncClient,
7743 db_session: AsyncSession,
7744 ) -> None:
7745 """Meter page must contain time signature, compound/simple badge, and beat strength UI."""
7746 await _make_repo(db_session)
7747 ref = "feedface5678"
7748 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/meter")
7749 assert response.status_code == 200
7750 body = response.text
7751 assert "Meter Analysis" in body
7752 assert "Time Signature" in body
7753 assert "Beat Strength Profile" in body
7754 # SSR migration (issue #578): beat strength is now rendered as inline CSS bars,
7755 # not as a JS function call. Verify the label is present and CSS bars are rendered.
7756 assert "border-radius" in body or "%" in body
7757
7758
7759 @pytest.mark.anyio
7760 async def test_chord_map_analysis_page_renders(
7761 client: AsyncClient,
7762 db_session: AsyncSession,
7763 ) -> None:
7764 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/chord-map returns 200 HTML."""
7765 await _make_repo(db_session)
7766 ref = "abc1234567890abcdef"
7767 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/chord-map")
7768 assert response.status_code == 200
7769 assert "text/html" in response.headers["content-type"]
7770
7771
7772 @pytest.mark.anyio
7773 async def test_chord_map_analysis_page_no_auth_required(
7774 client: AsyncClient,
7775 db_session: AsyncSession,
7776 ) -> None:
7777 """Chord-map analysis page must be accessible without a JWT (HTML shell handles auth)."""
7778 await _make_repo(db_session)
7779 ref = "deadbeef9999"
7780 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/chord-map")
7781 assert response.status_code != 401
7782 assert response.status_code == 200
7783
7784
7785 @pytest.mark.anyio
7786 async def test_chord_map_analysis_page_contains_chord_data_labels(
7787 client: AsyncClient,
7788 db_session: AsyncSession,
7789 ) -> None:
7790 """Chord-map page SSR: must contain progression timeline, chord table, and tension data."""
7791 await _make_repo(db_session)
7792 ref = "beefdead1234"
7793 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/chord-map")
7794 assert response.status_code == 200
7795 body = response.text
7796 assert "Chord Map" in body
7797 assert "PROGRESSION TIMELINE" in body
7798 assert "CHORD TABLE" in body
7799 assert "tension" in body.lower()
7800
7801
7802 @pytest.mark.anyio
7803 async def test_groove_analysis_page_renders(
7804 client: AsyncClient,
7805 db_session: AsyncSession,
7806 ) -> None:
7807 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/groove returns 200 HTML."""
7808 await _make_repo(db_session)
7809 ref = "abc1234567890abcdef"
7810 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/groove")
7811 assert response.status_code == 200
7812 assert "text/html" in response.headers["content-type"]
7813
7814
7815 @pytest.mark.anyio
7816 async def test_groove_analysis_page_no_auth_required(
7817 client: AsyncClient,
7818 db_session: AsyncSession,
7819 ) -> None:
7820 """Groove analysis page must be accessible without a JWT (HTML shell handles auth)."""
7821 await _make_repo(db_session)
7822 ref = "deadbeef4321"
7823 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/groove")
7824 assert response.status_code != 401
7825 assert response.status_code == 200
7826
7827
7828 @pytest.mark.anyio
7829 async def test_groove_analysis_page_contains_groove_data_labels(
7830 client: AsyncClient,
7831 db_session: AsyncSession,
7832 ) -> None:
7833 """Groove page must contain style badge, BPM, swing factor, and groove score UI."""
7834 await _make_repo(db_session)
7835 ref = "cafefeed5678"
7836 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/groove")
7837 assert response.status_code == 200
7838 body = response.text
7839 assert "Groove Analysis" in body
7840 assert "Style" in body
7841 assert "BPM" in body
7842 assert "Groove Score" in body
7843 assert "Swing Factor" in body
7844
7845
7846 @pytest.mark.anyio
7847 async def test_emotion_analysis_page_renders(
7848 client: AsyncClient,
7849 db_session: AsyncSession,
7850 ) -> None:
7851 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/emotion returns 200 HTML."""
7852 await _make_repo(db_session)
7853 ref = "abc1234567890abcdef"
7854 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/emotion")
7855 assert response.status_code == 200
7856 assert "text/html" in response.headers["content-type"]
7857
7858
7859 @pytest.mark.anyio
7860 async def test_emotion_analysis_page_no_auth_required(
7861 client: AsyncClient,
7862 db_session: AsyncSession,
7863 ) -> None:
7864 """Emotion analysis page must be accessible without a JWT (HTML shell handles auth)."""
7865 await _make_repo(db_session)
7866 ref = "deadbeef0001"
7867 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/emotion")
7868 assert response.status_code != 401
7869 assert response.status_code == 200
7870
7871
7872 @pytest.mark.anyio
7873 async def test_emotion_analysis_page_contains_emotion_data_labels(
7874 client: AsyncClient,
7875 db_session: AsyncSession,
7876 ) -> None:
7877 """Emotion page SSR: must contain SVG scatter plot and summary vector dimension bars."""
7878 await _make_repo(db_session)
7879 ref = "aabbccdd5678"
7880 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/emotion")
7881 assert response.status_code == 200
7882 body = response.text
7883 assert "Emotion Analysis" in body
7884 assert "SUMMARY VECTOR" in body
7885 assert "Valence" in body or "valence" in body
7886 assert "Tension" in body or "tension" in body
7887 assert "<circle" in body or "<svg" in body
7888
7889
7890 @pytest.mark.anyio
7891 async def test_form_analysis_page_renders(
7892 client: AsyncClient,
7893 db_session: AsyncSession,
7894 ) -> None:
7895 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/form returns 200 HTML."""
7896 await _make_repo(db_session)
7897 ref = "abc1234567890abcdef"
7898 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/form")
7899 assert response.status_code == 200
7900 assert "text/html" in response.headers["content-type"]
7901
7902
7903 @pytest.mark.anyio
7904 async def test_form_analysis_page_no_auth_required(
7905 client: AsyncClient,
7906 db_session: AsyncSession,
7907 ) -> None:
7908 """Form analysis page must be accessible without a JWT (HTML shell handles auth)."""
7909 await _make_repo(db_session)
7910 ref = "deadbeef0002"
7911 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/form")
7912 assert response.status_code != 401
7913 assert response.status_code == 200
7914
7915
7916 @pytest.mark.anyio
7917 async def test_form_analysis_page_contains_form_data_labels(
7918 client: AsyncClient,
7919 db_session: AsyncSession,
7920 ) -> None:
7921 """Form page must contain form label, section timeline, and sections table."""
7922 await _make_repo(db_session)
7923 ref = "11223344abcd"
7924 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/form")
7925 assert response.status_code == 200
7926 body = response.text
7927 assert "Form Analysis" in body
7928 assert "Form Timeline" in body or "formLabel" in body
7929 assert "Sections" in body
7930 assert "Total Beats" in body
7931
7932
7933 # ---------------------------------------------------------------------------
7934 # Issue #295 — Profile page: followers/following lists with user cards
7935 # ---------------------------------------------------------------------------
7936
7937 # test_profile_page_has_followers_following_tabs
7938 # test_profile_page_has_user_card_js
7939 # test_profile_page_has_switch_tab_js
7940 # test_followers_list_endpoint_returns_200
7941 # test_followers_list_returns_user_cards_for_known_user
7942 # test_following_list_returns_user_cards_for_known_user
7943 # test_followers_list_unknown_user_404
7944 # test_following_list_unknown_user_404
7945 # test_followers_response_includes_following_count
7946 # test_followers_list_empty_for_user_with_no_followers
7947
7948
7949 async def _make_follow(
7950 db_session: AsyncSession,
7951 follower_id: str,
7952 followee_id: str,
7953 ) -> MusehubFollow:
7954 """Seed a follow relationship and return the ORM row."""
7955 import uuid
7956 row = MusehubFollow(
7957 follow_id=str(uuid.uuid4()),
7958 follower_id=follower_id,
7959 followee_id=followee_id,
7960 )
7961 db_session.add(row)
7962 await db_session.commit()
7963 return row
7964
7965
7966 @pytest.mark.skip(reason="Pre-existing failure: profile page template missing tab-btn-followers element")
7967 @pytest.mark.anyio
7968 async def test_profile_page_has_followers_following_tabs(
7969 client: AsyncClient,
7970 db_session: AsyncSession,
7971 ) -> None:
7972 """Profile page must render Followers and Following tab buttons."""
7973 await _make_profile(db_session, username="tabuser")
7974 response = await client.get("/musehub/ui/users/tabuser")
7975 assert response.status_code == 200
7976 body = response.text
7977 assert "tab-btn-followers" in body
7978 assert "tab-btn-following" in body
7979
7980
7981 @pytest.mark.skip(reason="Pre-existing failure: profile page template missing userCardHtml JS")
7982 @pytest.mark.anyio
7983 async def test_profile_page_has_user_card_js(
7984 client: AsyncClient,
7985 db_session: AsyncSession,
7986 ) -> None:
7987 """Profile page must include userCardHtml and loadFollowTab JS helpers."""
7988 await _make_profile(db_session, username="cardjsuser")
7989 response = await client.get("/musehub/ui/users/cardjsuser")
7990 assert response.status_code == 200
7991 body = response.text
7992 assert "userCardHtml" in body
7993 assert "loadFollowTab" in body
7994
7995
7996 @pytest.mark.anyio
7997 async def test_profile_page_has_switch_tab_js(
7998 client: AsyncClient,
7999 db_session: AsyncSession,
8000 ) -> None:
8001 """Profile page must include switchTab() to toggle between followers and following."""
8002 await _make_profile(db_session, username="switchtabuser")
8003 response = await client.get("/musehub/ui/users/switchtabuser")
8004 assert response.status_code == 200
8005 assert "switchTab" in response.text
8006
8007
8008 @pytest.mark.anyio
8009 async def test_followers_list_endpoint_returns_200(
8010 client: AsyncClient,
8011 db_session: AsyncSession,
8012 ) -> None:
8013 """GET /api/v1/musehub/users/{username}/followers-list returns 200 for known user."""
8014 await _make_profile(db_session, username="followerlistuser")
8015 response = await client.get("/api/v1/musehub/users/followerlistuser/followers-list")
8016 assert response.status_code == 200
8017 assert isinstance(response.json(), list)
8018
8019
8020 @pytest.mark.anyio
8021 async def test_followers_list_returns_user_cards_for_known_user(
8022 client: AsyncClient,
8023 db_session: AsyncSession,
8024 ) -> None:
8025 """followers-list returns UserCard objects when followers exist."""
8026 import uuid
8027
8028 target = MusehubProfile(
8029 user_id="target-user-fl-01",
8030 username="flctarget",
8031 bio="I am the target",
8032 avatar_url=None,
8033 pinned_repo_ids=[],
8034 )
8035 follower = MusehubProfile(
8036 user_id="follower-user-fl-01",
8037 username="flcfollower",
8038 bio="I am a follower",
8039 avatar_url=None,
8040 pinned_repo_ids=[],
8041 )
8042 db_session.add(target)
8043 db_session.add(follower)
8044 await db_session.flush()
8045 # Seed a follow row using user_ids (same convention as the seed script)
8046 await _make_follow(db_session, follower_id="follower-user-fl-01", followee_id="target-user-fl-01")
8047
8048 response = await client.get("/api/v1/musehub/users/flctarget/followers-list")
8049 assert response.status_code == 200
8050 cards = response.json()
8051 assert len(cards) >= 1
8052 usernames = [c["username"] for c in cards]
8053 assert "flcfollower" in usernames
8054
8055
8056 @pytest.mark.anyio
8057 async def test_following_list_returns_user_cards_for_known_user(
8058 client: AsyncClient,
8059 db_session: AsyncSession,
8060 ) -> None:
8061 """following-list returns UserCard objects for users that the target follows."""
8062 actor = MusehubProfile(
8063 user_id="actor-user-fl-02",
8064 username="flcactor",
8065 bio="I follow people",
8066 avatar_url=None,
8067 pinned_repo_ids=[],
8068 )
8069 followee = MusehubProfile(
8070 user_id="followee-user-fl-02",
8071 username="flcfollowee",
8072 bio="I am followed",
8073 avatar_url=None,
8074 pinned_repo_ids=[],
8075 )
8076 db_session.add(actor)
8077 db_session.add(followee)
8078 await db_session.flush()
8079 await _make_follow(db_session, follower_id="actor-user-fl-02", followee_id="followee-user-fl-02")
8080
8081 response = await client.get("/api/v1/musehub/users/flcactor/following-list")
8082 assert response.status_code == 200
8083 cards = response.json()
8084 assert len(cards) >= 1
8085 usernames = [c["username"] for c in cards]
8086 assert "flcfollowee" in usernames
8087
8088
8089 @pytest.mark.anyio
8090 async def test_followers_list_unknown_user_404(
8091 client: AsyncClient,
8092 db_session: AsyncSession,
8093 ) -> None:
8094 """followers-list returns 404 when the target username does not exist."""
8095 response = await client.get("/api/v1/musehub/users/nonexistent-ghost-user/followers-list")
8096 assert response.status_code == 404
8097
8098
8099 @pytest.mark.anyio
8100 async def test_following_list_unknown_user_404(
8101 client: AsyncClient,
8102 db_session: AsyncSession,
8103 ) -> None:
8104 """following-list returns 404 when the target username does not exist."""
8105 response = await client.get("/api/v1/musehub/users/nonexistent-ghost-user/following-list")
8106 assert response.status_code == 404
8107
8108
8109 @pytest.mark.anyio
8110 async def test_followers_response_includes_following_count(
8111 client: AsyncClient,
8112 db_session: AsyncSession,
8113 ) -> None:
8114 """GET /users/{username}/followers now includes following_count in response."""
8115 await _make_profile(db_session, username="followcountuser")
8116 response = await client.get("/api/v1/musehub/users/followcountuser/followers")
8117 assert response.status_code == 200
8118 data = response.json()
8119 assert "followerCount" in data or "follower_count" in data
8120 assert "followingCount" in data or "following_count" in data
8121
8122
8123 @pytest.mark.anyio
8124 async def test_followers_list_empty_for_user_with_no_followers(
8125 client: AsyncClient,
8126 db_session: AsyncSession,
8127 ) -> None:
8128 """followers-list returns an empty list when no one follows the user."""
8129 await _make_profile(db_session, username="lonelyuser295")
8130 response = await client.get("/api/v1/musehub/users/lonelyuser295/followers-list")
8131 assert response.status_code == 200
8132 assert response.json() == []
8133
8134
8135 # ---------------------------------------------------------------------------
8136 # Issue #450 — Enhanced commit detail: inline audio player, muse_tags panel,
8137 # reactions, comment thread, cross-references
8138 # ---------------------------------------------------------------------------
8139
8140
8141 @pytest.mark.anyio
8142 async def test_commit_page_has_inline_audio_player_section(
8143 client: AsyncClient,
8144 db_session: AsyncSession,
8145 ) -> None:
8146 """Commit detail page (SSR, issue #583) renders WaveSurfer shell when snapshot_id is set.
8147
8148 Post-SSR migration: the audio player shell (commit-waveform + WaveSurfer script)
8149 is rendered only when the commit has a snapshot_id. Non-existent commits → 404.
8150 """
8151 from datetime import datetime, timezone
8152 from musehub.db.musehub_models import MusehubCommit
8153
8154 repo = MusehubRepo(
8155 name="audio-player-test",
8156 owner="audiouser",
8157 slug="audio-player-test",
8158 visibility="public",
8159 owner_user_id="audio-uid",
8160 )
8161 db_session.add(repo)
8162 await db_session.commit()
8163 await db_session.refresh(repo)
8164
8165 snap_id = "sha256:deadbeefcafe"
8166 commit_id = "c0ffee0000111122223333444455556666c0ffee"
8167 commit = MusehubCommit(
8168 commit_id=commit_id,
8169 repo_id=str(repo.repo_id),
8170 branch="main",
8171 parent_ids=[],
8172 message="Add audio snapshot",
8173 author="audiouser",
8174 timestamp=datetime.now(tz=timezone.utc),
8175 snapshot_id=snap_id,
8176 )
8177 db_session.add(commit)
8178 await db_session.commit()
8179
8180 response = await client.get(f"/musehub/ui/audiouser/audio-player-test/commits/{commit_id}")
8181 assert response.status_code == 200
8182 body = response.text
8183 # SSR audio shell: waveform div with data-url set from snapshot_id
8184 assert "commit-waveform" in body
8185 assert snap_id in body
8186 # WaveSurfer vendor script still loaded
8187 assert "wavesurfer" in body.lower()
8188 # Listen link rendered
8189 assert "Listen" in body
8190
8191
8192 @pytest.mark.anyio
8193 async def test_commit_page_inline_player_has_track_selector_js(
8194 client: AsyncClient,
8195 db_session: AsyncSession,
8196 ) -> None:
8197 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8198
8199 Track selector JS was part of the pre-SSR commit.html. The new commit_detail.html
8200 renders a simplified WaveSurfer shell from the commit's snapshot_id.
8201 Non-existent commits return 404 rather than an empty JS shell.
8202 """
8203 repo = MusehubRepo(
8204 name="track-sel-test",
8205 owner="trackuser",
8206 slug="track-sel-test",
8207 visibility="public",
8208 owner_user_id="track-uid",
8209 )
8210 db_session.add(repo)
8211 await db_session.commit()
8212 await db_session.refresh(repo)
8213
8214 commit_id = "aaaa1111bbbb2222cccc3333dddd4444eeee5555"
8215 response = await client.get(f"/musehub/ui/trackuser/track-sel-test/commits/{commit_id}")
8216 assert response.status_code == 404
8217
8218
8219 @pytest.mark.anyio
8220 async def test_commit_page_has_muse_tags_panel(
8221 client: AsyncClient,
8222 db_session: AsyncSession,
8223 ) -> None:
8224 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8225
8226 The muse-tags-panel was a JS-only construct in the pre-SSR commit.html.
8227 The new commit_detail.html renders metadata server-side; the muse-tags panel
8228 is not present. Non-existent commits return 404.
8229 """
8230 repo = MusehubRepo(
8231 name="tags-panel-test",
8232 owner="tagsuser",
8233 slug="tags-panel-test",
8234 visibility="public",
8235 owner_user_id="tags-uid",
8236 )
8237 db_session.add(repo)
8238 await db_session.commit()
8239 await db_session.refresh(repo)
8240
8241 commit_id = "1234567890abcdef1234567890abcdef12345678"
8242 response = await client.get(f"/musehub/ui/tagsuser/tags-panel-test/commits/{commit_id}")
8243 assert response.status_code == 404
8244
8245
8246 @pytest.mark.anyio
8247 async def test_commit_page_muse_tags_pill_colours_defined(
8248 client: AsyncClient,
8249 db_session: AsyncSession,
8250 ) -> None:
8251 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8252
8253 Muse-pill CSS classes were part of the pre-SSR commit.html analysis panel.
8254 The new commit_detail.html does not include muse-pill classes.
8255 Non-existent commits return 404.
8256 """
8257 repo = MusehubRepo(
8258 name="pill-colour-test",
8259 owner="pilluser",
8260 slug="pill-colour-test",
8261 visibility="public",
8262 owner_user_id="pill-uid",
8263 )
8264 db_session.add(repo)
8265 await db_session.commit()
8266 await db_session.refresh(repo)
8267
8268 commit_id = "abcd1234ef567890abcd1234ef567890abcd1234"
8269 response = await client.get(f"/musehub/ui/pilluser/pill-colour-test/commits/{commit_id}")
8270 assert response.status_code == 404
8271
8272
8273 @pytest.mark.anyio
8274 async def test_commit_page_has_cross_references_section(
8275 client: AsyncClient,
8276 db_session: AsyncSession,
8277 ) -> None:
8278 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8279
8280 The cross-references panel (xrefs-body, loadCrossReferences) was a JS-only
8281 construct in the pre-SSR commit.html. The new commit_detail.html does not
8282 include this panel. Non-existent commits return 404.
8283 """
8284 repo = MusehubRepo(
8285 name="xrefs-test",
8286 owner="xrefsuser",
8287 slug="xrefs-test",
8288 visibility="public",
8289 owner_user_id="xrefs-uid",
8290 )
8291 db_session.add(repo)
8292 await db_session.commit()
8293 await db_session.refresh(repo)
8294
8295 commit_id = "face000011112222333344445555666677778888"
8296 response = await client.get(f"/musehub/ui/xrefsuser/xrefs-test/commits/{commit_id}")
8297 assert response.status_code == 404
8298
8299
8300 @pytest.mark.anyio
8301 async def test_commit_page_context_passes_listen_and_embed_urls(
8302 client: AsyncClient,
8303 db_session: AsyncSession,
8304 ) -> None:
8305 """commit_page() (SSR, issue #583) injects listenUrl and embedUrl into the JS page-data block.
8306
8307 The SSR template still exposes these URLs server-side for the JS and for
8308 navigation links. Requires the commit to exist in the DB.
8309 """
8310 from datetime import datetime, timezone
8311 from musehub.db.musehub_models import MusehubCommit
8312
8313 repo = MusehubRepo(
8314 name="url-context-test",
8315 owner="urluser",
8316 slug="url-context-test",
8317 visibility="public",
8318 owner_user_id="url-uid",
8319 )
8320 db_session.add(repo)
8321 await db_session.commit()
8322 await db_session.refresh(repo)
8323
8324 commit_id = "dead0000beef1111dead0000beef1111dead0000"
8325 commit = MusehubCommit(
8326 commit_id=commit_id,
8327 repo_id=str(repo.repo_id),
8328 branch="main",
8329 parent_ids=[],
8330 message="URL context test commit",
8331 author="urluser",
8332 timestamp=datetime.now(tz=timezone.utc),
8333 )
8334 db_session.add(commit)
8335 await db_session.commit()
8336
8337 response = await client.get(f"/musehub/ui/urluser/url-context-test/commits/{commit_id}")
8338 assert response.status_code == 200
8339 body = response.text
8340 assert "listenUrl" in body
8341 assert "embedUrl" in body
8342 assert f"/listen/{commit_id}" in body
8343 assert f"/embed/{commit_id}" in body
8344
8345
8346 # ---------------------------------------------------------------------------
8347 # Issue #442 — Repo landing page enrichment panels
8348 # Explore page — filter sidebar + inline audio preview
8349 # ---------------------------------------------------------------------------
8350
8351
8352 @pytest.mark.anyio
8353 async def test_repo_home_contributors_panel_js(
8354 client: AsyncClient,
8355 db_session: AsyncSession,
8356 ) -> None:
8357 """Repo home page includes the contributors panel JS.
8358
8359 The panel calls the /credits endpoint and renders top-10 contributor
8360 avatars linked to user profile pages with a commit count badge.
8361 """
8362 repo = MusehubRepo(
8363 name="contrib-panel-test",
8364 owner="contribowner",
8365 slug="contrib-panel-test",
8366 visibility="public",
8367 owner_user_id="contrib-uid",
8368 )
8369 db_session.add(repo)
8370 await db_session.commit()
8371
8372 response = await client.get("/musehub/ui/contribowner/contrib-panel-test")
8373 assert response.status_code == 200
8374 body = response.text
8375 assert "loadContributors" in body
8376 assert "contributors-panel" in body
8377 assert "contrib-avatar" in body
8378 assert "contrib-badge" in body
8379 assert "credits" in body
8380
8381
8382 @pytest.mark.anyio
8383 async def test_repo_home_activity_heatmap_js(
8384 client: AsyncClient,
8385 db_session: AsyncSession,
8386 ) -> None:
8387 """Repo home page includes the activity heatmap JS.
8388
8389 The heatmap renders a 52-week GitHub-style grid from commit timestamps
8390 with tooltip-on-hover showing date and commit count.
8391 """
8392 repo = MusehubRepo(
8393 name="heatmap-panel-test",
8394 owner="heatmapowner",
8395 slug="heatmap-panel-test",
8396 visibility="public",
8397 owner_user_id="heatmap-uid",
8398 )
8399 db_session.add(repo)
8400 await db_session.commit()
8401
8402 response = await client.get("/musehub/ui/heatmapowner/heatmap-panel-test")
8403 assert response.status_code == 200
8404 body = response.text
8405 assert "loadActivityHeatmap" in body
8406 assert "activity-heatmap" in body
8407 assert "hm-cell" in body
8408 assert "hm-grid" in body
8409
8410
8411 @pytest.mark.anyio
8412 async def test_repo_home_instrument_bar_js(
8413 client: AsyncClient,
8414 db_session: AsyncSession,
8415 ) -> None:
8416 """Repo home page includes the instrument distribution bar JS.
8417
8418 The bar shows stacked segments labelled with instrument names and
8419 percentages, derived from MIDI/audio object paths in the latest commit.
8420 """
8421 repo = MusehubRepo(
8422 name="instrbar-panel-test",
8423 owner="instrbarowner",
8424 slug="instrbar-panel-test",
8425 visibility="public",
8426 owner_user_id="instrbar-uid",
8427 )
8428 db_session.add(repo)
8429 await db_session.commit()
8430
8431 response = await client.get("/musehub/ui/instrbarowner/instrbar-panel-test")
8432 assert response.status_code == 200
8433 body = response.text
8434 assert "loadInstrumentBar" in body
8435 assert "instrument-bar" in body
8436 assert "instr-bar" in body
8437 assert "instr-seg" in body
8438
8439
8440 @pytest.mark.anyio
8441 async def test_repo_home_clone_widget_renders(
8442 client: AsyncClient,
8443 db_session: AsyncSession,
8444 ) -> None:
8445 """Repo home page includes the clone widget with musehub://, SSH, and HTTPS URLs.
8446
8447 The widget shows three copy-to-clipboard inputs and a Download ZIP button.
8448 Clone URLs are injected server-side by repo_page() so the JS template
8449 never has to reconstruct them from owner/slug.
8450 """
8451 repo = MusehubRepo(
8452 name="clone-widget-test",
8453 owner="cloneowner",
8454 slug="clone-widget-test",
8455 visibility="public",
8456 owner_user_id="clone-uid",
8457 )
8458 db_session.add(repo)
8459 await db_session.commit()
8460
8461 response = await client.get("/musehub/ui/cloneowner/clone-widget-test")
8462 assert response.status_code == 200
8463 body = response.text
8464
8465 # Clone URL constants injected by repo_page()
8466 assert "CLONE_MUSEHUB" in body
8467 assert "musehub://cloneowner/clone-widget-test" in body
8468 assert "ssh://git@musehub.stori.app/cloneowner/clone-widget-test.git" in body
8469 assert "https://musehub.stori.app/cloneowner/clone-widget-test.git" in body
8470
8471 # DOM elements rendered by renderCloneWidget()
8472 assert "clone-widget" in body
8473 assert "renderCloneWidget" in body
8474 assert "clone-input" in body
8475 assert "copyClone" in body
8476 assert "Download ZIP" in body
8477 async def test_explore_page_returns_200(
8478 client: AsyncClient,
8479 ) -> None:
8480 """GET /musehub/ui/explore returns 200 without authentication."""
8481 response = await client.get("/musehub/ui/explore")
8482 assert response.status_code == 200
8483
8484
8485 @pytest.mark.anyio
8486 async def test_explore_page_has_filter_sidebar(
8487 client: AsyncClient,
8488 ) -> None:
8489 """Explore page renders a filter sidebar with sort, license, and clear-filters sections."""
8490 response = await client.get("/musehub/ui/explore")
8491 assert response.status_code == 200
8492 body = response.text
8493 assert "explore-sidebar" in body
8494 assert "Clear filters" in body
8495 assert "Sort by" in body
8496 assert "License" in body
8497
8498
8499 @pytest.mark.anyio
8500 async def test_explore_page_has_sort_options(
8501 client: AsyncClient,
8502 ) -> None:
8503 """Explore page sidebar includes all four sort radio options."""
8504 response = await client.get("/musehub/ui/explore")
8505 assert response.status_code == 200
8506 body = response.text
8507 assert "Most starred" in body
8508 assert "Recently updated" in body
8509 assert "Most forked" in body
8510 assert "Trending" in body
8511
8512
8513 @pytest.mark.anyio
8514 async def test_explore_page_has_license_options(
8515 client: AsyncClient,
8516 ) -> None:
8517 """Explore page sidebar includes the expected license filter options."""
8518 response = await client.get("/musehub/ui/explore")
8519 assert response.status_code == 200
8520 body = response.text
8521 assert "CC0" in body
8522 assert "CC BY" in body
8523 assert "CC BY-SA" in body
8524 assert "CC BY-NC" in body
8525 assert "All Rights Reserved" in body
8526
8527
8528 @pytest.mark.anyio
8529 async def test_explore_page_has_repo_grid(
8530 client: AsyncClient,
8531 ) -> None:
8532 """Explore page includes the repo grid and JS discover API loader."""
8533 response = await client.get("/musehub/ui/explore")
8534 assert response.status_code == 200
8535 body = response.text
8536 assert "repo-grid" in body
8537 assert "loadExplore" in body
8538 assert "DISCOVER_API" in body
8539
8540
8541 @pytest.mark.anyio
8542 async def test_explore_page_has_audio_preview_js(
8543 client: AsyncClient,
8544 ) -> None:
8545 """Explore page includes the inline hover audio preview JavaScript (500ms delay)."""
8546 response = await client.get("/musehub/ui/explore")
8547 assert response.status_code == 200
8548 body = response.text
8549 assert "card-audio-preview" in body
8550 assert "toggleCardPreview" in body
8551 assert "onCardMouseEnter" in body
8552 assert "500" in body
8553
8554
8555 @pytest.mark.anyio
8556 async def test_explore_page_default_sort_stars(
8557 client: AsyncClient,
8558 ) -> None:
8559 """Explore page defaults to 'stars' sort when no sort param given."""
8560 response = await client.get("/musehub/ui/explore")
8561 assert response.status_code == 200
8562 body = response.text
8563 # 'stars' radio should be pre-checked (default sort)
8564 assert 'value="stars"' in body
8565 assert 'checked' in body
8566
8567
8568 @pytest.mark.anyio
8569 async def test_explore_page_sort_param_honoured(
8570 client: AsyncClient,
8571 ) -> None:
8572 """Explore page honours the ?sort= query param for pre-selecting a sort option."""
8573 response = await client.get("/musehub/ui/explore?sort=updated")
8574 assert response.status_code == 200
8575 body = response.text
8576 assert 'value="updated"' in body
8577
8578
8579 @pytest.mark.anyio
8580 async def test_explore_page_no_auth_required(
8581 client: AsyncClient,
8582 ) -> None:
8583 """Explore page is publicly accessible — no JWT required (zero-friction discovery)."""
8584 response = await client.get("/musehub/ui/explore")
8585 assert response.status_code == 200
8586 assert response.status_code != 401
8587 assert response.status_code != 403
8588
8589
8590 @pytest.mark.anyio
8591 async def test_explore_page_chip_toggle_js(
8592 client: AsyncClient,
8593 ) -> None:
8594 """Explore page includes toggleChip JS for progressive chip filter enhancement."""
8595 response = await client.get("/musehub/ui/explore")
8596 assert response.status_code == 200
8597 body = response.text
8598 assert "toggleChip" in body
8599 assert "filter-chip" in body
8600
8601
8602 @pytest.mark.anyio
8603 async def test_explore_page_get_params_preserved(
8604 client: AsyncClient,
8605 ) -> None:
8606 """Explore page accepts lang, license, topic, sort GET params without error."""
8607 response = await client.get(
8608 "/musehub/ui/explore?lang=piano&license=CC0&topic=jazz&sort=stars"
8609 )
8610 assert response.status_code == 200