gabriel / musehub public
test_musehub_ui.py python
8609 lines 291.5 KB
c2319918 fix(ci): resolve all test failures blocking PR #3 Gabriel Cardona <gabriel@tellurstori.com> 6d ago
1 """Tests for Muse Hub web UI endpoints.
2
3 Covers (compare view):
4 - test_compare_page_renders — GET /musehub/ui/{owner}/{slug}/compare/{base}...{head} returns 200
5 - test_compare_page_no_auth_required — compare page accessible without JWT
6 - test_compare_page_invalid_ref_404 — refs without ... separator return 404
7 - test_compare_page_unknown_owner_404 — unknown owner/slug returns 404
8 - test_compare_page_includes_radar — SSR: all five dimension names present in HTML (replaces JS radar)
9 - test_compare_page_includes_piano_roll — SSR: dimension table header columns in HTML (replaces piano roll JS)
10 - test_compare_page_includes_emotion_diff — SSR: Change delta column present (replaces emotion diff JS)
11 - test_compare_page_includes_commit_list — SSR: all dimension rows present (replaces commit list JS)
12 - test_compare_page_includes_create_pr_button — SSR: both ref names in heading (replaces PR button CTA)
13 - test_compare_json_response — SSR: response is text/html with dimension data (no JSON negotiation)
14 - test_compare_unknown_ref_404 — unknown ref returns 404
15
16
17 Covers acceptance criteria (commit list page):
18 - test_commits_list_page_returns_200 — GET /{owner}/{repo}/commits returns HTML
19 - test_commits_list_page_shows_commit_sha — SHA of seeded commit appears in page
20 - test_commits_list_page_shows_commit_message — message appears in page
21 - test_commits_list_page_dag_indicator — DAG node element present
22 - test_commits_list_page_pagination_links — Older/Newer nav links present when multi-page
23 - test_commits_list_page_branch_selector — branch <select> present when branches exist
24 - test_commits_list_page_json_content_negotiation — ?format=json returns CommitListResponse
25 - test_commits_list_page_json_pagination — ?format=json&per_page=1&page=2 returns page 2
26 - test_commits_list_page_branch_filter_html — ?branch=main filters to that branch
27 - test_commits_list_page_empty_state — repo with no commits shows empty state
28 - test_commits_list_page_merge_indicator — merge commit shows merge indicator
29 - test_commits_list_page_graph_link — link to DAG graph page present
30
31 Covers the minimum acceptance criteria and :
32 - test_ui_repo_page_returns_200 — GET /musehub/ui/{repo_id} returns HTML
33 - test_ui_commit_page_shows_artifact_links — commit page HTML mentions img/download
34 - test_ui_pr_list_page_returns_200 — PR list page renders without error
35 - test_ui_issue_list_page_returns_200 — Issue list page renders without error
36 - test_ui_issue_list_has_open_closed_tabs — Open/Closed tab buttons present
37 - test_ui_issue_list_has_sort_controls — Sort buttons (newest/oldest/most-commented) present
38 - test_ui_issue_list_has_label_filter_js — Client-side label filter JS present
39 - test_ui_issue_list_has_body_preview_js — Body preview helper and CSS class present
40 - test_ui_issue_detail_has_comment_section — Comment thread section below issue body
41 - test_ui_issue_detail_has_render_comments_js — buildCommentThread() renders the comment thread
42 - test_ui_issue_detail_has_submit_comment_js — submitComment() posts new comments
43 - test_ui_issue_detail_has_delete_comment_js — deleteComment() removes own comments
44 - test_ui_issue_detail_has_reply_support_js — startReply() enables threaded replies
45 - test_ui_issue_detail_comment_section_below_body — comment section follows issue body card
46 - test_ui_pr_list_has_comment_badge_js — PR list has comment count badge JS
47 - test_ui_pr_list_has_reaction_pills_js — PR list has reaction pills JS
48 - test_ui_issue_list_has_reaction_pills_js — Issue list has reaction pills JS
49 - test_ui_issue_list_eager_social_signals — Issue list eagerly pre-fetches social signals
50 - test_context_page_renders — context viewer page returns 200 HTML
51 - test_context_json_response — JSON returns MuseHubContextResponse structure
52 - test_context_includes_musical_state — response includes active_tracks field
53 - test_context_unknown_ref_404 — nonexistent ref returns 404
54
55 Covers acceptance criteria (tree browser):
56 - test_tree_root_lists_directories
57 - test_tree_subdirectory_lists_files
58 - test_tree_file_icons_by_type
59 - test_tree_breadcrumbs_correct
60 - test_tree_json_response
61 - test_tree_unknown_ref_404
62
63 Covers acceptance criteria (embed player):
64 - test_embed_page_renders — GET /musehub/ui/{repo_id}/embed/{ref} returns 200
65 - test_embed_no_auth_required — Public embed accessible without JWT
66 - test_embed_page_x_frame_options — Response sets X-Frame-Options: ALLOWALL
67 - test_embed_page_contains_player_ui — Player elements present in embed HTML
68
69 Covers (emotion map page), migrated to owner/slug routing:
70 - test_emotion_page_renders — GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/emotion returns 200
71 - test_emotion_page_no_auth_required — emotion UI page accessible without JWT
72 - test_emotion_page_includes_charts — page embeds valence-arousal plot and axis labels
73 - test_emotion_page_includes_filters — page includes primary emotion and confidence display
74 - test_emotion_json_response — JSON endpoint returns emotion map with required fields
75 - test_emotion_trajectory — cross-commit trajectory data is present and ordered
76 - test_emotion_drift_distances — drift list has one entry per consecutive commit pair
77
78 Covers (rich event cards in activity feed):
79 - test_feed_page_returns_200 — GET /musehub/ui/feed returns 200 HTML
80 - test_feed_page_no_raw_json_payload — page does not render raw JSON.stringify of payload
81 - test_feed_page_has_event_meta_for_all_types — EVENT_META covers all 8 event types
82 - test_feed_page_has_data_notif_id_attribute — cards carry data-notif-id for mark-as-read hook
83 - test_feed_page_has_unread_indicator — unread highlight border logic present
84 - test_feed_page_has_actor_avatar_logic — actorAvatar / actorHsl helper present
85 - test_feed_page_has_relative_timestamp — fmtRelative called in card rendering
86
87 Covers (mark-as-read UX in activity feed):
88 - test_feed_page_has_mark_one_read_function — markOneRead() defined for per-card action
89 - test_feed_page_has_mark_all_read_function — markAllRead() defined for bulk action
90 - test_feed_page_has_decrement_nav_badge_function — decrementNavBadge() keeps badge in sync
91 - test_feed_page_mark_read_btn_targets_notification_endpoint — calls POST /notifications/{id}/read
92 - test_feed_page_mark_all_btn_targets_read_all_endpoint — calls POST /notifications/read-all
93 - test_feed_page_mark_all_btn_present_in_template — mark-all-read-btn element in page HTML
94 - test_feed_page_mark_read_updates_nav_badge — nav-notif-badge updated after mark-all
95
96 UI routes require no JWT auth (they return HTML shells whose JS handles auth).
97 The HTML content tests assert structural markers present in every rendered page.
98
99 Covers (release detail comment threads):
100 - test_ui_release_detail_has_comment_section — Discussion section present in HTML
101 - test_ui_release_detail_has_render_comments_js — renderComments/submitComment/deleteComment JS present
102 - test_ui_release_detail_comment_uses_release_target_type — target_type='release' used in JS
103 - test_ui_release_detail_has_reply_thread_js — toggleReplyForm/submitReply for thread support
104
105 Covers (commit comment threads):
106 - test_commit_page_has_comment_section_html — comments-section container present in HTML
107 - test_commit_page_has_comment_js_functions — renderComments/submitComment/deleteComment/loadComments JS present
108 - test_commit_page_comment_calls_load_on_startup — loadComments() called at page startup
109 - test_commit_page_comment_uses_correct_api_path — fetches /comments?target_type=commit
110 - test_commit_page_comment_has_avatar_logic — avatarColor() HSL helper present
111 - test_commit_page_comment_has_new_comment_form — new-comment textarea form present
112 - test_commit_page_comment_has_discussion_heading"Discussion" heading present
113
114 Covers regression for PR #282 (owner/slug URL scheme):
115 - test_ui_nav_links_use_owner_slug_not_uuid_* — every page handler injects
116 ``const base = '/musehub/ui/{owner}/{slug}'`` not a UUID-based path.
117 - test_ui_unknown_owner_slug_returns_404 — bad owner/slug → 404.
118
119 Covers (analysis dashboard):
120 - test_analysis_dashboard_renders — GET /musehub/ui/{owner}/{slug}/analysis/{ref} returns 200
121 - test_analysis_dashboard_no_auth_required — accessible without JWT
122 - test_analysis_dashboard_all_dimension_labels — 10 dimension labels present in HTML
123 - test_analysis_dashboard_sparkline_logic_present — sparkline JS present
124 - test_analysis_dashboard_card_links_to_dimensions — /analysis/ path in page
125 See also test_musehub_analysis.py::test_analysis_aggregate_endpoint_returns_all_dimensions
126
127 Covers (branch list and tag browser):
128 - test_branches_page_lists_all — GET /musehub/ui/{owner}/{slug}/branches returns 200 HTML
129 - test_branches_default_marked — default branch badge present in JSON response
130 - test_branches_compare_link — compare link JS present on branches page
131 - test_branches_new_pr_button — new pull request link JS present
132 - test_branches_json_response — JSON returns BranchDetailListResponse with ahead/behind
133 - test_tags_page_lists_all — GET /musehub/ui/{owner}/{slug}/tags returns 200 HTML
134 - test_tags_namespace_filter — namespace filter JS present on tags page
135 - test_tags_json_response — JSON returns TagListResponse with namespace grouping
136
137 Covers (audio player — listen page):
138 - test_listen_page_renders — GET /musehub/ui/{owner}/{slug}/listen/{ref} returns 200
139 - test_listen_page_no_auth_required — listen page accessible without JWT
140 - test_listen_page_contains_waveform_ui — waveform container and controls present
141 - test_listen_page_contains_play_button — play button element present in HTML
142 - test_listen_page_contains_speed_selector — speed selector element present
143 - test_listen_page_contains_ab_loop_ui — A/B loop controls present
144 - test_listen_page_loads_wavesurfer_vendor — page loads vendored wavesurfer.min.js (no CDN)
145 - test_listen_page_loads_audio_player_js — page loads audio-player.js component script
146 - test_listen_track_page_renders — GET /musehub/ui/{owner}/{slug}/listen/{ref}/{path} returns 200
147 - test_listen_track_page_has_track_path_in_js — track path injected into page JS context
148 - test_listen_page_unknown_repo_404 — bad owner/slug → 404
149 - test_listen_page_keyboard_shortcuts_documented — keyboard shortcuts mentioned in page
150 """
151 from __future__ import annotations
152
153 from datetime import UTC, datetime, timedelta, timezone
154
155 import pytest
156 from httpx import AsyncClient
157 from sqlalchemy.ext.asyncio import AsyncSession
158
159 from musehub.db.musehub_models import (
160 MusehubBranch,
161 MusehubCommit,
162 MusehubFollow,
163 MusehubFork,
164 MusehubObject,
165 MusehubProfile,
166 MusehubRelease,
167 MusehubRepo,
168 MusehubSession,
169 MusehubStar,
170 MusehubWatch,
171 )
172
173
174 # ---------------------------------------------------------------------------
175 # Helpers
176 # ---------------------------------------------------------------------------
177
178
179 async def _make_repo(db_session: AsyncSession) -> str:
180 """Seed a minimal repo and return its repo_id."""
181 repo = MusehubRepo(
182 name="test-beats",
183 owner="testuser",
184 slug="test-beats",
185 visibility="private",
186 owner_user_id="test-owner",
187 )
188 db_session.add(repo)
189 await db_session.commit()
190 await db_session.refresh(repo)
191 return str(repo.repo_id)
192
193
194 _TEST_USER_ID = "550e8400-e29b-41d4-a716-446655440000"
195
196
197 async def _make_profile(db_session: AsyncSession, username: str = "testmusician") -> MusehubProfile:
198 """Seed a minimal profile and return it."""
199 profile = MusehubProfile(
200 user_id=_TEST_USER_ID,
201 username=username,
202 bio="Test bio",
203 avatar_url=None,
204 pinned_repo_ids=[],
205 )
206 db_session.add(profile)
207 await db_session.commit()
208 await db_session.refresh(profile)
209 return profile
210
211
212 async def _make_public_repo(db_session: AsyncSession) -> str:
213 """Seed a public repo for the test user and return its repo_id."""
214 repo = MusehubRepo(
215 name="public-beats",
216 owner="testuser",
217 slug="public-beats",
218 visibility="public",
219 owner_user_id=_TEST_USER_ID,
220 )
221 db_session.add(repo)
222 await db_session.commit()
223 await db_session.refresh(repo)
224 return str(repo.repo_id)
225
226
227 # ---------------------------------------------------------------------------
228 # UI route tests (no auth required — routes return HTML)
229 # ---------------------------------------------------------------------------
230
231
232 @pytest.mark.anyio
233 async def test_ui_repo_page_returns_200(
234 client: AsyncClient,
235 db_session: AsyncSession,
236 ) -> None:
237 """GET /musehub/ui/{repo_id} returns 200 HTML without requiring a JWT."""
238 repo_id = await _make_repo(db_session)
239 response = await client.get("/musehub/ui/testuser/test-beats")
240 assert response.status_code == 200
241 assert "text/html" in response.headers["content-type"]
242 body = response.text
243 # Verify shared chrome is present
244 assert "Muse Hub" in body
245 assert "test-beats" in body # repo slug is shown in the page header/title
246 # Verify page-specific content is present (repo home page — file tree + clone section)
247 assert "file-tree" in body or "Empty repository" in body
248
249
250 @pytest.mark.anyio
251 async def test_ui_commit_page_shows_artifact_links(
252 client: AsyncClient,
253 db_session: AsyncSession,
254 ) -> None:
255 """GET /musehub/ui/{owner}/{slug}/commits/{commit_id} returns SSR HTML for a known commit.
256
257 Post-SSR migration (issue #583): commit_page() now requires the commit to exist in the
258 DB (returns 404 otherwise) and renders metadata + comments server-side.
259 """
260 from datetime import datetime, timezone
261 from musehub.db.musehub_models import MusehubCommit
262
263 repo_id = await _make_repo(db_session)
264 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
265 commit = MusehubCommit(
266 commit_id=commit_id,
267 repo_id=repo_id,
268 branch="main",
269 parent_ids=[],
270 message="Add bridge section",
271 author="testuser",
272 timestamp=datetime.now(tz=timezone.utc),
273 )
274 db_session.add(commit)
275 await db_session.commit()
276
277 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
278 assert response.status_code == 200
279 assert "text/html" in response.headers["content-type"]
280 body = response.text
281 # SSR: commit message and metadata appear server-side
282 assert "Add bridge section" in body
283 assert "testuser" in body
284 # Links to listen and embed pages present
285 assert f"/listen/{commit_id}" in body
286 assert f"/embed/{commit_id}" in body
287
288
289 @pytest.mark.anyio
290 async def test_ui_pr_list_page_returns_200(
291 client: AsyncClient,
292 db_session: AsyncSession,
293 ) -> None:
294 """GET /musehub/ui/{repo_id}/pulls returns 200 HTML without requiring a JWT."""
295 repo_id = await _make_repo(db_session)
296 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
297 assert response.status_code == 200
298 assert "text/html" in response.headers["content-type"]
299 body = response.text
300 assert "Pull Requests" in body
301 assert "Muse Hub" in body
302 # State filter select element must be present in the JS
303 assert "state" in body
304
305
306 @pytest.mark.anyio
307 async def test_ui_pr_list_has_state_tabs(
308 client: AsyncClient,
309 db_session: AsyncSession,
310 ) -> None:
311 """PR list page includes Open, Merged, Closed, and All tab buttons with counts."""
312 await _make_repo(db_session)
313 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
314 assert response.status_code == 200
315 body = response.text
316 # All four tab IDs must be present
317 assert "tab-open" in body
318 assert "tab-merged" in body
319 assert "tab-closed" in body
320 assert "tab-all" in body
321 # Tab count placeholder class must be present
322 assert "tab-count" in body
323 # Tab labels must be present
324 assert "Open" in body
325 assert "Merged" in body
326 assert "Closed" in body
327 assert "All" in body
328
329
330 @pytest.mark.anyio
331 async def test_ui_pr_list_has_body_preview_js(
332 client: AsyncClient,
333 db_session: AsyncSession,
334 ) -> None:
335 """PR list page HTML includes bodyPreview JS function for subtitle truncation."""
336 await _make_repo(db_session)
337 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
338 assert response.status_code == 200
339 body = response.text
340 assert "bodyPreview" in body
341 assert "issue-preview" in body
342
343
344 @pytest.mark.anyio
345 async def test_ui_pr_list_has_branch_pills(
346 client: AsyncClient,
347 db_session: AsyncSession,
348 ) -> None:
349 """PR list page HTML includes branch-pill CSS class for from/to branch indicators."""
350 await _make_repo(db_session)
351 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
352 assert response.status_code == 200
353 body = response.text
354 assert "branch-pill" in body
355 assert "fromBranch" in body
356 assert "toBranch" in body
357
358
359 @pytest.mark.anyio
360 async def test_ui_pr_list_has_sort_controls(
361 client: AsyncClient,
362 db_session: AsyncSession,
363 ) -> None:
364 """PR list page HTML includes Newest and Oldest sort buttons."""
365 await _make_repo(db_session)
366 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
367 assert response.status_code == 200
368 body = response.text
369 assert "Newest" in body
370 assert "Oldest" in body
371 assert "changeSort" in body
372 assert "sort-btn" in body
373
374
375 @pytest.mark.anyio
376 async def test_ui_pr_list_has_merged_badge_markup(
377 client: AsyncClient,
378 db_session: AsyncSession,
379 ) -> None:
380 """PR list page JS renders a Merged badge with merge commit short-SHA link for merged PRs."""
381 await _make_repo(db_session)
382 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
383 assert response.status_code == 200
384 body = response.text
385 # JS logic for merged state and merge commit rendering
386 assert "badge-merged" in body
387 assert "mergeCommitId" in body
388 assert "slice(0, 8)" in body
389
390
391 @pytest.mark.anyio
392 async def test_ui_pr_list_has_closed_badge_markup(
393 client: AsyncClient,
394 db_session: AsyncSession,
395 ) -> None:
396 """PR list page JS renders a Closed badge for closed PRs."""
397 await _make_repo(db_session)
398 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
399 assert response.status_code == 200
400 body = response.text
401 assert "badge-closed" in body
402
403
404 @pytest.mark.anyio
405 async def test_ui_issue_list_page_returns_200(
406 client: AsyncClient,
407 db_session: AsyncSession,
408 ) -> None:
409 """GET /musehub/ui/{repo_id}/issues returns 200 HTML without requiring a JWT."""
410 repo_id = await _make_repo(db_session)
411 response = await client.get("/musehub/ui/testuser/test-beats/issues")
412 assert response.status_code == 200
413 assert "text/html" in response.headers["content-type"]
414 body = response.text
415 assert "Issues" in body
416 assert "Muse Hub" in body
417
418
419 @pytest.mark.anyio
420 async def test_ui_issue_list_has_open_closed_tabs(
421 client: AsyncClient,
422 db_session: AsyncSession,
423 ) -> None:
424 """Issue list page HTML includes Open and Closed tab buttons and count spans."""
425 await _make_repo(db_session)
426 response = await client.get("/musehub/ui/testuser/test-beats/issues")
427 assert response.status_code == 200
428 body = response.text
429 # Tab buttons for open and closed state
430 assert "tab-open" in body
431 assert "tab-closed" in body
432 # Tab count placeholders are rendered client-side; structural markers exist
433 assert "tab-count" in body
434 assert "Open" in body
435 assert "Closed" in body
436
437
438 @pytest.mark.anyio
439 async def test_ui_issue_list_has_sort_controls(
440 client: AsyncClient,
441 db_session: AsyncSession,
442 ) -> None:
443 """Issue list page HTML includes Newest, Oldest, and Most commented sort controls.
444
445 The issue list uses SSR radio buttons with server-side sort parameters
446 (converted from client-side changeSort() as part of the HTMX migration).
447 """
448 await _make_repo(db_session)
449 response = await client.get("/musehub/ui/testuser/test-beats/issues")
450 assert response.status_code == 200
451 body = response.text
452 assert "Newest" in body
453 assert "Oldest" in body
454 assert "Most commented" in body
455 assert "sort-radio-group" in body
456
457
458 @pytest.mark.anyio
459 async def test_ui_issue_list_has_label_filter_js(
460 client: AsyncClient,
461 db_session: AsyncSession,
462 ) -> None:
463 """Issue list page HTML includes client-side label filter logic."""
464 await _make_repo(db_session)
465 response = await client.get("/musehub/ui/testuser/test-beats/issues")
466 assert response.status_code == 200
467 body = response.text
468 # JS function for label filtering
469 assert "setLabelFilter" in body
470 assert "label-pill" in body
471 assert "activeLabel" in body
472
473
474 @pytest.mark.anyio
475 async def test_ui_issue_list_has_body_preview_js(
476 client: AsyncClient,
477 db_session: AsyncSession,
478 ) -> None:
479 """Issue list page HTML includes bodyPreview helper for truncated body subtitles."""
480 await _make_repo(db_session)
481 response = await client.get("/musehub/ui/testuser/test-beats/issues")
482 assert response.status_code == 200
483 body = response.text
484 assert "bodyPreview" in body
485 assert "issue-preview" in body
486
487
488 @pytest.mark.anyio
489 async def test_ui_pr_list_has_comment_badge_js(
490 client: AsyncClient,
491 db_session: AsyncSession,
492 ) -> None:
493 """PR list page HTML includes JS for fetching and rendering comment count badges."""
494 await _make_repo(db_session)
495 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
496 assert response.status_code == 200
497 body = response.text
498 # Comment count badge rendered via loadSocialSignals
499 assert "loadSocialSignals" in body
500 assert "prCommentCounts" in body
501 assert "&#128172;" in body # 💬 comment icon HTML entity
502
503
504 @pytest.mark.anyio
505 async def test_ui_pr_list_has_reaction_pills_js(
506 client: AsyncClient,
507 db_session: AsyncSession,
508 ) -> None:
509 """PR list page HTML includes JS for fetching and rendering reaction pills."""
510 await _make_repo(db_session)
511 response = await client.get("/musehub/ui/testuser/test-beats/pulls")
512 assert response.status_code == 200
513 body = response.text
514 # Reaction pills rendered via reactionPills helper
515 assert "reactionPills" in body
516 assert "prReactions" in body
517 # Reactions fetched from the reactions API endpoint
518 assert "reactions?target_type=pull_request" in body
519
520
521 @pytest.mark.anyio
522 async def test_ui_issue_list_has_reaction_pills_js(
523 client: AsyncClient,
524 db_session: AsyncSession,
525 ) -> None:
526 """Issue list page HTML includes JS for fetching and rendering reaction pills."""
527 await _make_repo(db_session)
528 response = await client.get("/musehub/ui/testuser/test-beats/issues")
529 assert response.status_code == 200
530 body = response.text
531 # Reaction summary state and loader
532 assert "issueReactions" in body
533 assert "loadReactionSummaries" in body
534 # Reactions fetched from the reactions API endpoint
535 assert "reactions?target_type=issue" in body
536 # Reaction pills rendered inline in issue rows
537 assert "rxnPills" in body
538
539
540 @pytest.mark.anyio
541 async def test_ui_issue_list_eager_social_signals(
542 client: AsyncClient,
543 db_session: AsyncSession,
544 ) -> None:
545 """Issue list page eagerly pre-fetches comment counts and reactions on load."""
546 await _make_repo(db_session)
547 response = await client.get("/musehub/ui/testuser/test-beats/issues")
548 assert response.status_code == 200
549 body = response.text
550 # Eager pre-fetch block calls both loaders before rendering rows
551 assert "needCounts" in body
552 assert "needRxns" in body
553 assert "loadCommentCounts" in body
554 assert "loadReactionSummaries" in body
555
556
557 @pytest.mark.anyio
558 async def test_ui_pr_detail_page_returns_200(
559 client: AsyncClient,
560 db_session: AsyncSession,
561 ) -> None:
562 """GET /musehub/ui/{repo_id}/pulls/{pr_id} returns 200 HTML."""
563 repo_id = await _make_repo(db_session)
564 pr_id = "some-pr-uuid-1234"
565 response = await client.get(f"/musehub/ui/testuser/test-beats/pulls/{pr_id}")
566 assert response.status_code == 200
567 assert "text/html" in response.headers["content-type"]
568 body = response.text
569 assert "Muse Hub" in body
570 assert "Merge pull request" in body
571
572
573 @pytest.mark.anyio
574 async def test_ui_pr_detail_page_has_comment_section(
575 client: AsyncClient,
576 db_session: AsyncSession,
577 ) -> None:
578 """PR detail page includes threaded comment UI and reaction bar."""
579 await _make_repo(db_session)
580 pr_id = "some-pr-uuid-comment-test"
581 response = await client.get(f"/musehub/ui/testuser/test-beats/pulls/{pr_id}")
582 assert response.status_code == 200
583 body = response.text
584 assert "comment-section" in body
585 assert "comment-list" in body
586 assert "refreshComments" in body
587 assert "submitComment" in body
588 assert "deleteComment" in body
589
590
591 @pytest.mark.anyio
592 async def test_ui_pr_detail_page_has_reaction_bar(
593 client: AsyncClient,
594 db_session: AsyncSession,
595 ) -> None:
596 """PR detail page includes a reaction bar that calls loadReactions."""
597 await _make_repo(db_session)
598 pr_id = "some-pr-uuid-reaction-test"
599 response = await client.get(f"/musehub/ui/testuser/test-beats/pulls/{pr_id}")
600 assert response.status_code == 200
601 body = response.text
602 assert "pr-reactions" in body
603 assert "loadReactions" in body
604 assert "reaction-bar" in body
605
606
607 @pytest.mark.anyio
608 async def test_pr_detail_shows_diff_radar(
609 client: AsyncClient,
610 db_session: AsyncSession,
611 ) -> None:
612 """PR detail page HTML contains the musical diff radar chart container."""
613 await _make_repo(db_session)
614 response = await client.get("/musehub/ui/testuser/test-beats/pulls/some-pr-id")
615 assert response.status_code == 200
616 body = response.text
617 # radarSvg function and DIMENSIONS constant must be present
618 assert "radarSvg" in body
619 assert "DIMENSIONS" in body
620
621
622 @pytest.mark.anyio
623 async def test_pr_detail_audio_ab(
624 client: AsyncClient,
625 db_session: AsyncSession,
626 ) -> None:
627 """PR detail page HTML contains before/after audio A/B toggle controls."""
628 await _make_repo(db_session)
629 response = await client.get("/musehub/ui/testuser/test-beats/pulls/some-pr-id")
630 assert response.status_code == 200
631 body = response.text
632 assert "btn-audio-from" in body
633 assert "btn-audio-to" in body
634 assert "toggleAudio" in body
635 assert "Audio A/B Comparison" in body
636
637
638 @pytest.mark.anyio
639 async def test_pr_detail_merge_strategies(
640 client: AsyncClient,
641 db_session: AsyncSession,
642 ) -> None:
643 """PR detail page HTML contains the merge strategy selector buttons."""
644 await _make_repo(db_session)
645 response = await client.get("/musehub/ui/testuser/test-beats/pulls/some-pr-id")
646 assert response.status_code == 200
647 body = response.text
648 assert "strategy-merge_commit" in body
649 assert "strategy-squash" in body
650 assert "strategy-rebase" in body
651 assert "selectStrategy" in body
652 assert "mergePrWithStrategy" in body
653
654
655 @pytest.mark.anyio
656 async def test_pr_detail_json_response(
657 client: AsyncClient,
658 db_session: AsyncSession,
659 auth_headers: dict[str, str],
660 ) -> None:
661 """PR detail page ?format=json returns structured diff data for agent consumption."""
662 from datetime import datetime, timezone
663
664 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubPullRequest
665
666 repo_id = await _make_repo(db_session)
667 commit_id = "aabbccddeeff00112233445566778899aabbccdd"
668 commit = MusehubCommit(
669 commit_id=commit_id,
670 repo_id=repo_id,
671 branch="feat/blues-riff",
672 parent_ids=[],
673 message="Add harmonic chord progression in Dm",
674 author="musician",
675 timestamp=datetime.now(tz=timezone.utc),
676 )
677 branch = MusehubBranch(
678 repo_id=repo_id,
679 name="feat/blues-riff",
680 head_commit_id=commit_id,
681 )
682 db_session.add(commit)
683 db_session.add(branch)
684 import uuid
685 pr_id = uuid.uuid4().hex
686 pr = MusehubPullRequest(
687 pr_id=pr_id,
688 repo_id=repo_id,
689 title="Add blues riff",
690 body="",
691 state="open",
692 from_branch="feat/blues-riff",
693 to_branch="main",
694 author="musician",
695 )
696 db_session.add(pr)
697 await db_session.commit()
698
699 response = await client.get(
700 f"/musehub/ui/testuser/test-beats/pulls/{pr_id}?format=json",
701 headers=auth_headers,
702 )
703 assert response.status_code == 200
704 data = response.json()
705 # Must include dimension scores and overall score
706 assert "dimensions" in data
707 assert "overallScore" in data
708 assert isinstance(data["dimensions"], list)
709 assert len(data["dimensions"]) == 5
710 assert data["prId"] == pr_id
711 assert data["fromBranch"] == "feat/blues-riff"
712 assert data["toBranch"] == "main"
713 # Each dimension must have the expected fields
714 dim = data["dimensions"][0]
715 assert "dimension" in dim
716 assert "score" in dim
717 assert "level" in dim
718 assert "deltaLabel" in dim
719
720
721 # ---------------------------------------------------------------------------
722 # Tests for musehub_divergence service helpers
723 # ---------------------------------------------------------------------------
724
725
726 def test_extract_affected_sections_empty_when_no_section_keywords() -> None:
727 """affected_sections returns [] when no commit message mentions a section keyword."""
728 from musehub.services.musehub_divergence import extract_affected_sections
729
730 messages = (
731 "Add jazzy chord progression in Dm",
732 "Rework drum pattern for more swing",
733 "Fix melody pitch drift on lead synth",
734 )
735 assert extract_affected_sections(messages) == []
736
737
738 def test_extract_affected_sections_finds_mentioned_keywords() -> None:
739 """affected_sections returns only section keywords actually present in commit messages."""
740 from musehub.services.musehub_divergence import extract_affected_sections
741
742 messages = (
743 "Rewrite chorus melody to be more catchy",
744 "Add tension to the bridge section",
745 "Clean up drum loop in verse 2",
746 )
747 result = extract_affected_sections(messages)
748 assert "Chorus" in result
749 assert "Bridge" in result
750 assert "Verse" in result
751 # Keywords NOT mentioned should not appear
752 assert "Intro" not in result
753 assert "Outro" not in result
754
755
756 def test_extract_affected_sections_case_insensitive() -> None:
757 """Section keyword matching is case-insensitive."""
758 from musehub.services.musehub_divergence import extract_affected_sections
759
760 messages = ("CHORUS rework", "New INTRO material", "bridge transition")
761 result = extract_affected_sections(messages)
762 assert "Chorus" in result
763 assert "Intro" in result
764 assert "Bridge" in result
765
766
767 def test_extract_affected_sections_deduplicates() -> None:
768 """Each keyword appears at most once even when mentioned in multiple commits."""
769 from musehub.services.musehub_divergence import extract_affected_sections
770
771 messages = ("fix chorus", "rewrite chorus progression", "shorten chorus tail")
772 result = extract_affected_sections(messages)
773 assert result.count("Chorus") == 1
774
775
776 def test_build_pr_diff_response_affected_sections_from_commits() -> None:
777 """build_pr_diff_response populates affected_sections from commit messages, not score."""
778 from musehub.services.musehub_divergence import (
779 MuseHubDimensionDivergence,
780 MuseHubDivergenceLevel,
781 MuseHubDivergenceResult,
782 build_pr_diff_response,
783 )
784
785 structural_dim = MuseHubDimensionDivergence(
786 dimension="structural",
787 level=MuseHubDivergenceLevel.HIGH,
788 score=0.9,
789 description="High structural divergence",
790 branch_a_commits=3,
791 branch_b_commits=0,
792 )
793 other_dims = tuple(
794 MuseHubDimensionDivergence(
795 dimension=dim,
796 level=MuseHubDivergenceLevel.NONE,
797 score=0.0,
798 description=f"No {dim} changes.",
799 branch_a_commits=0,
800 branch_b_commits=0,
801 )
802 for dim in ("melodic", "harmonic", "rhythmic", "dynamic")
803 )
804 result = MuseHubDivergenceResult(
805 repo_id="repo-1",
806 branch_a="main",
807 branch_b="feat/new-structure",
808 common_ancestor="abc123",
809 dimensions=(structural_dim,) + other_dims,
810 overall_score=0.18,
811 # No section keywords in any commit message → affected_sections should be []
812 all_messages=("Add chord progression", "Refine drum groove"),
813 )
814
815 response = build_pr_diff_response(
816 pr_id="pr-abc",
817 from_branch="feat/new-structure",
818 to_branch="main",
819 result=result,
820 )
821
822 assert response.affected_sections == []
823 assert response.overall_score == 0.18
824 assert len(response.dimensions) == 5
825
826
827 def test_build_pr_diff_response_affected_sections_present_when_mentioned() -> None:
828 """build_pr_diff_response returns affected_sections when commits mention section keywords."""
829 from musehub.services.musehub_divergence import (
830 MuseHubDimensionDivergence,
831 MuseHubDivergenceLevel,
832 MuseHubDivergenceResult,
833 build_pr_diff_response,
834 )
835
836 dims = tuple(
837 MuseHubDimensionDivergence(
838 dimension=dim,
839 level=MuseHubDivergenceLevel.NONE,
840 score=0.0,
841 description=f"No {dim} changes.",
842 branch_a_commits=0,
843 branch_b_commits=0,
844 )
845 for dim in ("melodic", "harmonic", "rhythmic", "structural", "dynamic")
846 )
847 result = MuseHubDivergenceResult(
848 repo_id="repo-2",
849 branch_a="main",
850 branch_b="feat/chorus-rework",
851 common_ancestor="def456",
852 dimensions=dims,
853 overall_score=0.0,
854 all_messages=("Rework the chorus hook", "Add bridge leading into outro"),
855 )
856
857 response = build_pr_diff_response(
858 pr_id="pr-def",
859 from_branch="feat/chorus-rework",
860 to_branch="main",
861 result=result,
862 )
863
864 assert "Chorus" in response.affected_sections
865 assert "Bridge" in response.affected_sections
866 assert "Outro" in response.affected_sections
867 assert "Verse" not in response.affected_sections
868
869
870 def test_build_zero_diff_response_returns_empty_affected_sections() -> None:
871 """build_zero_diff_response always returns [] for affected_sections."""
872 from musehub.services.musehub_divergence import build_zero_diff_response
873
874 response = build_zero_diff_response(
875 pr_id="pr-zero",
876 repo_id="repo-zero",
877 from_branch="feat/empty",
878 to_branch="main",
879 )
880
881 assert response.affected_sections == []
882 assert response.overall_score == 0.0
883 assert all(d.score == 0.0 for d in response.dimensions)
884 assert len(response.dimensions) == 5
885
886
887 @pytest.mark.anyio
888 async def test_diff_api_affected_sections_empty_without_section_keywords(
889 client: AsyncClient,
890 db_session: AsyncSession,
891 auth_headers: dict[str, str],
892 ) -> None:
893 """GET /diff returns affected_sections=[] when commit messages mention no section keywords."""
894 import uuid
895 from datetime import datetime, timezone
896
897 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubPullRequest
898
899 repo_id = await _make_repo(db_session)
900 commit_id = uuid.uuid4().hex
901 commit = MusehubCommit(
902 commit_id=commit_id,
903 repo_id=repo_id,
904 branch="feat/harmonic-twist",
905 parent_ids=[],
906 message="Add jazzy chord progression in Dm",
907 author="musician",
908 timestamp=datetime.now(tz=timezone.utc),
909 )
910 branch = MusehubBranch(
911 repo_id=repo_id,
912 name="feat/harmonic-twist",
913 head_commit_id=commit_id,
914 )
915 pr_id = uuid.uuid4().hex
916 pr = MusehubPullRequest(
917 pr_id=pr_id,
918 repo_id=repo_id,
919 title="Harmonic twist",
920 body="",
921 state="open",
922 from_branch="feat/harmonic-twist",
923 to_branch="main",
924 author="musician",
925 )
926 db_session.add_all([commit, branch, pr])
927 await db_session.commit()
928
929 response = await client.get(
930 f"/api/v1/musehub/repos/{repo_id}/pull-requests/{pr_id}/diff",
931 headers=auth_headers,
932 )
933 assert response.status_code == 200
934 data = response.json()
935 assert data["affectedSections"] == []
936
937
938 @pytest.mark.anyio
939 async def test_diff_api_affected_sections_populated_from_commit_message(
940 client: AsyncClient,
941 db_session: AsyncSession,
942 auth_headers: dict[str, str],
943 ) -> None:
944 """GET /diff returns affected_sections populated from commit messages mentioning sections."""
945 import uuid
946 from datetime import datetime, timezone
947
948 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubPullRequest
949
950 repo_id = await _make_repo(db_session)
951 # main branch needs at least one commit for compute_hub_divergence to succeed
952 main_commit_id = uuid.uuid4().hex
953 main_commit = MusehubCommit(
954 commit_id=main_commit_id,
955 repo_id=repo_id,
956 branch="main",
957 parent_ids=[],
958 message="Initial composition",
959 author="musician",
960 timestamp=datetime.now(tz=timezone.utc),
961 )
962 main_branch = MusehubBranch(
963 repo_id=repo_id,
964 name="main",
965 head_commit_id=main_commit_id,
966 )
967 commit_id = uuid.uuid4().hex
968 commit = MusehubCommit(
969 commit_id=commit_id,
970 repo_id=repo_id,
971 branch="feat/chorus-rework",
972 parent_ids=[main_commit_id],
973 message="Rewrite the chorus to be more energetic and add new bridge",
974 author="musician",
975 timestamp=datetime.now(tz=timezone.utc),
976 )
977 branch = MusehubBranch(
978 repo_id=repo_id,
979 name="feat/chorus-rework",
980 head_commit_id=commit_id,
981 )
982 pr_id = uuid.uuid4().hex
983 pr = MusehubPullRequest(
984 pr_id=pr_id,
985 repo_id=repo_id,
986 title="Chorus rework",
987 body="",
988 state="open",
989 from_branch="feat/chorus-rework",
990 to_branch="main",
991 author="musician",
992 )
993 db_session.add_all([main_commit, main_branch, commit, branch, pr])
994 await db_session.commit()
995
996 response = await client.get(
997 f"/api/v1/musehub/repos/{repo_id}/pull-requests/{pr_id}/diff",
998 headers=auth_headers,
999 )
1000 assert response.status_code == 200
1001 data = response.json()
1002 sections = data["affectedSections"]
1003 assert "Chorus" in sections
1004 assert "Bridge" in sections
1005 assert "Verse" not in sections
1006
1007
1008 @pytest.mark.anyio
1009 async def test_ui_diff_json_affected_sections_from_commit_message(
1010 client: AsyncClient,
1011 db_session: AsyncSession,
1012 auth_headers: dict[str, str],
1013 ) -> None:
1014 """PR detail ?format=json returns affected_sections derived from commit messages."""
1015 import uuid
1016 from datetime import datetime, timezone
1017
1018 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubPullRequest
1019
1020 repo_id = await _make_repo(db_session)
1021 # main branch needs at least one commit for compute_hub_divergence to succeed
1022 main_commit_id = uuid.uuid4().hex
1023 main_commit = MusehubCommit(
1024 commit_id=main_commit_id,
1025 repo_id=repo_id,
1026 branch="main",
1027 parent_ids=[],
1028 message="Initial composition",
1029 author="musician",
1030 timestamp=datetime.now(tz=timezone.utc),
1031 )
1032 main_branch = MusehubBranch(
1033 repo_id=repo_id,
1034 name="main",
1035 head_commit_id=main_commit_id,
1036 )
1037 commit_id = uuid.uuid4().hex
1038 commit = MusehubCommit(
1039 commit_id=commit_id,
1040 repo_id=repo_id,
1041 branch="feat/verse-update",
1042 parent_ids=[main_commit_id],
1043 message="Extend verse 2 with new melodic motif",
1044 author="musician",
1045 timestamp=datetime.now(tz=timezone.utc),
1046 )
1047 branch = MusehubBranch(
1048 repo_id=repo_id,
1049 name="feat/verse-update",
1050 head_commit_id=commit_id,
1051 )
1052 pr_id = uuid.uuid4().hex
1053 pr = MusehubPullRequest(
1054 pr_id=pr_id,
1055 repo_id=repo_id,
1056 title="Verse update",
1057 body="",
1058 state="open",
1059 from_branch="feat/verse-update",
1060 to_branch="main",
1061 author="musician",
1062 )
1063 db_session.add_all([main_commit, main_branch, commit, branch, pr])
1064 await db_session.commit()
1065
1066 response = await client.get(
1067 f"/musehub/ui/testuser/test-beats/pulls/{pr_id}?format=json",
1068 headers=auth_headers,
1069 )
1070 assert response.status_code == 200
1071 data = response.json()
1072 assert "Verse" in data["affectedSections"]
1073 assert "Chorus" not in data["affectedSections"]
1074
1075
1076 @pytest.mark.anyio
1077 async def test_ui_issue_detail_page_returns_200(
1078 client: AsyncClient,
1079 db_session: AsyncSession,
1080 ) -> None:
1081 """GET /musehub/ui/{repo_id}/issues/{number} returns 200 HTML."""
1082 repo_id = await _make_repo(db_session)
1083 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
1084 assert response.status_code == 200
1085 assert "text/html" in response.headers["content-type"]
1086 body = response.text
1087 assert "Muse Hub" in body
1088 assert "Close issue" in body
1089
1090
1091 @pytest.mark.anyio
1092 async def test_ui_issue_detail_has_comment_section(
1093 client: AsyncClient,
1094 db_session: AsyncSession,
1095 ) -> None:
1096 """Issue detail page includes a comment thread section below the issue body."""
1097 await _make_repo(db_session)
1098 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
1099 assert response.status_code == 200
1100 body = response.text
1101 assert "comments-container" in body
1102 assert "Discussion" in body
1103 assert "comment-input" in body
1104
1105
1106 @pytest.mark.anyio
1107 async def test_ui_issue_detail_has_render_comments_js(
1108 client: AsyncClient,
1109 db_session: AsyncSession,
1110 ) -> None:
1111 """Issue detail page embeds buildCommentThread() for rendering the comment thread."""
1112 await _make_repo(db_session)
1113 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
1114 assert response.status_code == 200
1115 body = response.text
1116 assert "buildCommentThread" in body
1117 assert "/comments" in body
1118
1119
1120 @pytest.mark.anyio
1121 async def test_ui_issue_detail_has_submit_comment_js(
1122 client: AsyncClient,
1123 db_session: AsyncSession,
1124 ) -> None:
1125 """Issue detail page embeds submitComment() for posting new comments."""
1126 await _make_repo(db_session)
1127 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
1128 assert response.status_code == 200
1129 body = response.text
1130 assert "submitComment" in body
1131 assert "comment-form" in body
1132
1133
1134 @pytest.mark.anyio
1135 async def test_ui_issue_detail_has_delete_comment_js(
1136 client: AsyncClient,
1137 db_session: AsyncSession,
1138 ) -> None:
1139 """Issue detail page embeds deleteComment() for removing own comments."""
1140 await _make_repo(db_session)
1141 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
1142 assert response.status_code == 200
1143 body = response.text
1144 assert "deleteComment" in body
1145
1146
1147 @pytest.mark.anyio
1148 async def test_ui_issue_detail_has_reply_support_js(
1149 client: AsyncClient,
1150 db_session: AsyncSession,
1151 ) -> None:
1152 """Issue detail page embeds startReply() for threaded replies via reply indicator."""
1153 await _make_repo(db_session)
1154 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
1155 assert response.status_code == 200
1156 body = response.text
1157 assert "startReply" in body
1158 assert "parentId" in body
1159
1160
1161 @pytest.mark.anyio
1162 async def test_ui_issue_detail_comment_section_below_body(
1163 client: AsyncClient,
1164 db_session: AsyncSession,
1165 ) -> None:
1166 """Comment section appears after the issue body card in document order."""
1167 await _make_repo(db_session)
1168 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
1169 assert response.status_code == 200
1170 body = response.text
1171 close_pos = body.find("closeIssue")
1172 comments_pos = body.find("comments-container")
1173 assert close_pos != -1, "closeIssue not found"
1174 assert comments_pos != -1, "comments-container not found"
1175 assert comments_pos > close_pos, "comment section must appear after the issue body"
1176
1177
1178 @pytest.mark.anyio
1179 async def test_ui_repo_page_no_auth_required(
1180 client: AsyncClient,
1181 db_session: AsyncSession,
1182 ) -> None:
1183 """UI routes must be accessible without an Authorization header."""
1184 repo_id = await _make_repo(db_session)
1185 response = await client.get("/musehub/ui/testuser/test-beats")
1186 # Must NOT return 401 — HTML shell has no auth requirement
1187 assert response.status_code != 401
1188 assert response.status_code == 200
1189
1190
1191 @pytest.mark.anyio
1192 async def test_ui_pages_include_token_form(
1193 client: AsyncClient,
1194 db_session: AsyncSession,
1195 ) -> None:
1196 """Every UI page embeds the JWT token input form so unauthenticated visitors can sign in."""
1197 repo_id = await _make_repo(db_session)
1198 for path in [
1199 "/musehub/ui/testuser/test-beats",
1200 "/musehub/ui/testuser/test-beats/pulls",
1201 "/musehub/ui/testuser/test-beats/issues",
1202 "/musehub/ui/testuser/test-beats/releases",
1203 ]:
1204 response = await client.get(path)
1205 assert response.status_code == 200
1206 body = response.text
1207 # musehub.js (which contains localStorage helpers) must be loaded
1208 assert "musehub/static/musehub.js" in body
1209 assert "token-form" in body
1210
1211
1212 @pytest.mark.anyio
1213 async def test_ui_release_list_page_returns_200(
1214 client: AsyncClient,
1215 db_session: AsyncSession,
1216 ) -> None:
1217 """GET /musehub/ui/{repo_id}/releases returns 200 HTML without requiring a JWT."""
1218 repo_id = await _make_repo(db_session)
1219 response = await client.get("/musehub/ui/testuser/test-beats/releases")
1220 assert response.status_code == 200
1221 assert "text/html" in response.headers["content-type"]
1222 body = response.text
1223 assert "Releases" in body
1224 assert "Muse Hub" in body
1225 assert repo_id[:8] in body
1226
1227
1228 @pytest.mark.anyio
1229 async def test_ui_release_list_page_has_download_buttons(
1230 client: AsyncClient,
1231 db_session: AsyncSession,
1232 ) -> None:
1233 """Release list page template includes inline download icon buttons for all package types."""
1234 await _make_repo(db_session)
1235 response = await client.get("/musehub/ui/testuser/test-beats/releases")
1236 assert response.status_code == 200
1237 body = response.text
1238 # All four download package types must appear in the JS template source.
1239 assert "MIDI" in body
1240 assert "Stems" in body
1241 assert "MP3" in body
1242 assert "MusicXML" in body
1243 # The helper function that builds download buttons must be present.
1244 assert "downloadButtons" in body
1245
1246
1247 @pytest.mark.anyio
1248 async def test_ui_release_list_page_has_body_preview(
1249 client: AsyncClient,
1250 db_session: AsyncSession,
1251 ) -> None:
1252 """Release list page template includes body preview logic (stripMarkdown / bodyPreview)."""
1253 await _make_repo(db_session)
1254 response = await client.get("/musehub/ui/testuser/test-beats/releases")
1255 assert response.status_code == 200
1256 body = response.text
1257 assert "stripMarkdown" in body
1258 assert "bodyPreview" in body
1259
1260
1261 @pytest.mark.anyio
1262 async def test_ui_release_list_page_has_download_count_badge(
1263 client: AsyncClient,
1264 db_session: AsyncSession,
1265 ) -> None:
1266 """Release list page fetches the analytics endpoint to show a download count badge."""
1267 await _make_repo(db_session)
1268 response = await client.get("/musehub/ui/testuser/test-beats/releases")
1269 assert response.status_code == 200
1270 body = response.text
1271 # The page must fetch analytics to populate the download count.
1272 assert "/analytics" in body
1273 assert "downloadCount" in body
1274 assert "download-badge" in body
1275
1276
1277 @pytest.mark.anyio
1278 async def test_ui_release_list_page_has_commit_link(
1279 client: AsyncClient,
1280 db_session: AsyncSession,
1281 ) -> None:
1282 """Release list page links each release's commit_id to the commit detail page."""
1283 await _make_repo(db_session)
1284 response = await client.get("/musehub/ui/testuser/test-beats/releases")
1285 assert response.status_code == 200
1286 body = response.text
1287 # Commit link renders first 8 chars of commit_id via substring(0, 8).
1288 assert "commitId" in body
1289 assert "substring(0, 8)" in body or "substring(0,8)" in body
1290 # Commit detail URL pattern must be embedded in the JS template.
1291 assert "/commits/" in body
1292
1293
1294 @pytest.mark.anyio
1295 async def test_ui_release_list_page_has_tag_colour_coding(
1296 client: AsyncClient,
1297 db_session: AsyncSession,
1298 ) -> None:
1299 """Release list page colour-codes tags: v1.x = green (stable), v0.x = yellow (pre-release)."""
1300 await _make_repo(db_session)
1301 response = await client.get("/musehub/ui/testuser/test-beats/releases")
1302 assert response.status_code == 200
1303 body = response.text
1304 # CSS classes for stable and pre-release tags must be present.
1305 assert "tag-stable" in body
1306 assert "tag-prerelease" in body
1307 # The JS function that assigns these classes must be present.
1308 assert "tagBadgeClass" in body
1309
1310
1311 @pytest.mark.anyio
1312 async def test_ui_release_detail_page_returns_200(
1313 client: AsyncClient,
1314 db_session: AsyncSession,
1315 ) -> None:
1316 """GET /musehub/ui/{repo_id}/releases/{tag} returns 200 HTML with download section."""
1317 repo_id = await _make_repo(db_session)
1318 response = await client.get("/musehub/ui/testuser/test-beats/releases/v1.0")
1319 assert response.status_code == 200
1320 assert "text/html" in response.headers["content-type"]
1321 body = response.text
1322 assert "Muse Hub" in body
1323 assert "Download" in body
1324 assert "v1.0" in body
1325
1326
1327 @pytest.mark.anyio
1328 async def test_ui_release_detail_has_comment_section(
1329 client: AsyncClient,
1330 db_session: AsyncSession,
1331 ) -> None:
1332 """Release detail page HTML includes the Discussion comment section."""
1333 await _make_repo(db_session)
1334 response = await client.get("/musehub/ui/testuser/test-beats/releases/v1.0")
1335 assert response.status_code == 200
1336 body = response.text
1337 assert "comments-section" in body
1338 assert "Discussion" in body
1339
1340
1341 @pytest.mark.anyio
1342 async def test_ui_release_detail_has_render_comments_js(
1343 client: AsyncClient,
1344 db_session: AsyncSession,
1345 ) -> None:
1346 """Release detail page includes renderComments() JavaScript function."""
1347 await _make_repo(db_session)
1348 response = await client.get("/musehub/ui/testuser/test-beats/releases/v1.0")
1349 assert response.status_code == 200
1350 body = response.text
1351 assert "renderComments" in body
1352 assert "submitComment" in body
1353 assert "deleteComment" in body
1354
1355
1356 @pytest.mark.anyio
1357 async def test_ui_release_detail_comment_uses_release_target_type(
1358 client: AsyncClient,
1359 db_session: AsyncSession,
1360 ) -> None:
1361 """Release detail page posts comments with target_type='release'."""
1362 await _make_repo(db_session)
1363 response = await client.get("/musehub/ui/testuser/test-beats/releases/v1.0")
1364 assert response.status_code == 200
1365 body = response.text
1366 assert "target_type: 'release'" in body or "target_type':'release'" in body or "target_type: \"release\"" in body
1367
1368
1369 @pytest.mark.anyio
1370 async def test_ui_release_detail_has_reply_thread_js(
1371 client: AsyncClient,
1372 db_session: AsyncSession,
1373 ) -> None:
1374 """Release detail page includes toggleReplyForm and submitReply for threaded comments."""
1375 await _make_repo(db_session)
1376 response = await client.get("/musehub/ui/testuser/test-beats/releases/v1.0")
1377 assert response.status_code == 200
1378 body = response.text
1379 assert "toggleReplyForm" in body
1380 assert "submitReply" in body
1381
1382
1383 @pytest.mark.anyio
1384 async def test_ui_repo_page_shows_releases_button(
1385 client: AsyncClient,
1386 db_session: AsyncSession,
1387 ) -> None:
1388 """GET /musehub/ui/{repo_id} includes a Releases navigation button."""
1389 repo_id = await _make_repo(db_session)
1390 response = await client.get("/musehub/ui/testuser/test-beats")
1391 assert response.status_code == 200
1392 body = response.text
1393 assert "releases" in body.lower()
1394
1395
1396 # ---------------------------------------------------------------------------
1397 # Global search UI page tests
1398 # ---------------------------------------------------------------------------
1399
1400
1401 @pytest.mark.anyio
1402 async def test_global_search_ui_page_returns_200(
1403 client: AsyncClient,
1404 db_session: AsyncSession,
1405 ) -> None:
1406 """GET /musehub/ui/search returns 200 HTML (no auth required — HTML shell)."""
1407 response = await client.get("/musehub/ui/search")
1408 assert response.status_code == 200
1409 assert "text/html" in response.headers["content-type"]
1410 body = response.text
1411 assert "Global Search" in body
1412 assert "Muse Hub" in body
1413
1414
1415 @pytest.mark.anyio
1416 async def test_global_search_ui_page_no_auth_required(
1417 client: AsyncClient,
1418 db_session: AsyncSession,
1419 ) -> None:
1420 """GET /musehub/ui/search must not return 401 — it is a static HTML shell."""
1421 response = await client.get("/musehub/ui/search")
1422 assert response.status_code != 401
1423 assert response.status_code == 200
1424
1425
1426 # ---------------------------------------------------------------------------
1427 # Object listing endpoint tests (JSON, authed)
1428 # ---------------------------------------------------------------------------
1429
1430
1431 @pytest.mark.anyio
1432 async def test_list_objects_returns_empty_for_new_repo(
1433 client: AsyncClient,
1434 db_session: AsyncSession,
1435 auth_headers: dict[str, str],
1436 ) -> None:
1437 """GET /api/v1/musehub/repos/{repo_id}/objects returns empty list for new repo."""
1438 repo_id = await _make_repo(db_session)
1439 response = await client.get(
1440 f"/api/v1/musehub/repos/{repo_id}/objects",
1441 headers=auth_headers,
1442 )
1443 assert response.status_code == 200
1444 assert response.json()["objects"] == []
1445
1446
1447 @pytest.mark.anyio
1448 async def test_list_objects_requires_auth(
1449 client: AsyncClient,
1450 db_session: AsyncSession,
1451 ) -> None:
1452 """GET /api/v1/musehub/repos/{repo_id}/objects returns 401 without auth."""
1453 repo_id = await _make_repo(db_session)
1454 response = await client.get(f"/api/v1/musehub/repos/{repo_id}/objects")
1455 assert response.status_code == 401
1456
1457
1458 @pytest.mark.anyio
1459 async def test_list_objects_404_for_unknown_repo(
1460 client: AsyncClient,
1461 db_session: AsyncSession,
1462 auth_headers: dict[str, str],
1463 ) -> None:
1464 """GET /api/v1/musehub/repos/{unknown}/objects returns 404."""
1465 response = await client.get(
1466 "/api/v1/musehub/repos/does-not-exist/objects",
1467 headers=auth_headers,
1468 )
1469 assert response.status_code == 404
1470
1471
1472 @pytest.mark.anyio
1473 async def test_get_object_content_404_for_unknown_object(
1474 client: AsyncClient,
1475 db_session: AsyncSession,
1476 auth_headers: dict[str, str],
1477 ) -> None:
1478 """GET /api/v1/musehub/repos/{repo_id}/objects/{unknown}/content returns 404."""
1479 repo_id = await _make_repo(db_session)
1480 response = await client.get(
1481 f"/api/v1/musehub/repos/{repo_id}/objects/sha256:notexist/content",
1482 headers=auth_headers,
1483 )
1484 assert response.status_code == 404
1485
1486
1487 # ---------------------------------------------------------------------------
1488 # Credits UI page tests
1489 # DAG graph UI page tests
1490 # ---------------------------------------------------------------------------
1491
1492
1493 @pytest.mark.anyio
1494 async def test_credits_page_renders(
1495 client: AsyncClient,
1496 db_session: AsyncSession,
1497 ) -> None:
1498 """GET /musehub/ui/{owner}/{repo_slug}/credits returns 200 HTML without requiring a JWT."""
1499 repo_id = await _make_repo(db_session)
1500 response = await client.get("/musehub/ui/testuser/test-beats/credits")
1501 assert response.status_code == 200
1502 assert "text/html" in response.headers["content-type"]
1503 body = response.text
1504 assert "Muse Hub" in body
1505 assert "Credits" in body
1506
1507
1508 @pytest.mark.anyio
1509
1510
1511 async def test_graph_page_renders(
1512 client: AsyncClient,
1513 db_session: AsyncSession,
1514 ) -> None:
1515 """GET /musehub/ui/{repo_id}/graph returns 200 HTML without requiring a JWT."""
1516 repo_id = await _make_repo(db_session)
1517 response = await client.get("/musehub/ui/testuser/test-beats/graph")
1518 assert response.status_code == 200
1519 assert "text/html" in response.headers["content-type"]
1520 body = response.text
1521 assert "Muse Hub" in body
1522 assert "graph" in body.lower()
1523
1524
1525 # ---------------------------------------------------------------------------
1526 # Context viewer tests
1527 # ---------------------------------------------------------------------------
1528
1529 _FIXED_COMMIT_ID = "aabbccdd" * 8 # 64-char hex string
1530
1531
1532 async def _make_repo_with_commit(db_session: AsyncSession) -> tuple[str, str]:
1533 """Seed a repo with one commit and return (repo_id, commit_id)."""
1534 repo = MusehubRepo(
1535 name="jazz-context-test",
1536 owner="testuser",
1537 slug="jazz-context-test",
1538 visibility="private",
1539 owner_user_id="test-owner",
1540 )
1541 db_session.add(repo)
1542 await db_session.flush()
1543 await db_session.refresh(repo)
1544 repo_id = str(repo.repo_id)
1545
1546 commit = MusehubCommit(
1547 commit_id=_FIXED_COMMIT_ID,
1548 repo_id=repo_id,
1549 branch="main",
1550 parent_ids=[],
1551 message="Add bass and drums",
1552 author="test-musician",
1553 timestamp=datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc),
1554 )
1555 db_session.add(commit)
1556 await db_session.commit()
1557 return repo_id, _FIXED_COMMIT_ID
1558
1559
1560 @pytest.mark.anyio
1561 async def test_context_page_renders(
1562 client: AsyncClient,
1563 db_session: AsyncSession,
1564 ) -> None:
1565 """GET /musehub/ui/{repo_id}/context/{ref} returns 200 HTML without auth."""
1566 repo_id, commit_id = await _make_repo_with_commit(db_session)
1567 response = await client.get(f"/musehub/ui/testuser/jazz-context-test/context/{commit_id}")
1568 assert response.status_code == 200
1569 assert "text/html" in response.headers["content-type"]
1570 body = response.text
1571 assert "Muse Hub" in body
1572 assert "context" in body.lower()
1573 assert repo_id[:8] in body
1574
1575
1576 @pytest.mark.anyio
1577 async def test_credits_json_response(
1578 client: AsyncClient,
1579 db_session: AsyncSession,
1580 auth_headers: dict[str, str],
1581 ) -> None:
1582 """GET /api/v1/musehub/repos/{repo_id}/credits returns JSON with required fields."""
1583 repo_id = await _make_repo(db_session)
1584 response = await client.get(
1585 f"/api/v1/musehub/repos/{repo_id}/credits",
1586 headers=auth_headers,
1587 )
1588 assert response.status_code == 200
1589 body = response.json()
1590 assert "repoId" in body
1591 assert "contributors" in body
1592 assert "sort" in body
1593 assert "totalContributors" in body
1594 assert body["repoId"] == repo_id
1595 assert isinstance(body["contributors"], list)
1596 assert body["sort"] == "count"
1597
1598
1599
1600 @pytest.mark.anyio
1601 async def test_context_json_response(
1602 client: AsyncClient,
1603 db_session: AsyncSession,
1604 auth_headers: dict[str, str],
1605 ) -> None:
1606 """GET /api/v1/musehub/repos/{repo_id}/context/{ref} returns MuseHubContextResponse."""
1607 repo_id, commit_id = await _make_repo_with_commit(db_session)
1608 response = await client.get(
1609 f"/api/v1/musehub/repos/{repo_id}/context/{commit_id}",
1610 headers=auth_headers,
1611 )
1612 assert response.status_code == 200
1613 body = response.json()
1614 assert "repoId" in body
1615
1616 assert body["repoId"] == repo_id
1617 assert body["currentBranch"] == "main"
1618 assert "headCommit" in body
1619 assert body["headCommit"]["commitId"] == commit_id
1620 assert body["headCommit"]["author"] == "test-musician"
1621 assert "musicalState" in body
1622 assert "history" in body
1623 assert "missingElements" in body
1624 assert "suggestions" in body
1625
1626
1627 @pytest.mark.anyio
1628 async def test_credits_empty_state_json(
1629 client: AsyncClient,
1630 db_session: AsyncSession,
1631 auth_headers: dict[str, str],
1632 ) -> None:
1633 """Repo with no commits returns empty contributors list and totalContributors=0."""
1634 repo_id = await _make_repo(db_session)
1635 response = await client.get(
1636 f"/api/v1/musehub/repos/{repo_id}/credits",
1637 headers=auth_headers,
1638 )
1639 assert response.status_code == 200
1640 body = response.json()
1641 assert body["contributors"] == []
1642 assert body["totalContributors"] == 0
1643
1644
1645 @pytest.mark.anyio
1646 async def test_context_includes_musical_state(
1647 client: AsyncClient,
1648 db_session: AsyncSession,
1649 auth_headers: dict[str, str],
1650 ) -> None:
1651
1652 """Context response includes musicalState with an activeTracks field."""
1653 repo_id, commit_id = await _make_repo_with_commit(db_session)
1654 response = await client.get(
1655 f"/api/v1/musehub/repos/{repo_id}/context/{commit_id}",
1656 headers=auth_headers,
1657 )
1658 assert response.status_code == 200
1659 musical_state = response.json()["musicalState"]
1660 assert "activeTracks" in musical_state
1661 assert isinstance(musical_state["activeTracks"], list)
1662 # Dimensions requiring MIDI analysis are None at this stage
1663 assert musical_state["key"] is None
1664 assert musical_state["tempoBpm"] is None
1665
1666
1667 @pytest.mark.anyio
1668 async def test_context_unknown_ref_404(
1669 client: AsyncClient,
1670 db_session: AsyncSession,
1671 auth_headers: dict[str, str],
1672 ) -> None:
1673 """GET /api/v1/musehub/repos/{repo_id}/context/{ref} returns 404 for unknown ref."""
1674 repo_id = await _make_repo(db_session)
1675 response = await client.get(
1676 f"/api/v1/musehub/repos/{repo_id}/context/deadbeef" + "0" * 56,
1677 headers=auth_headers,
1678 )
1679 assert response.status_code == 404
1680
1681
1682 @pytest.mark.anyio
1683 async def test_context_unknown_repo_404(
1684 client: AsyncClient,
1685 db_session: AsyncSession,
1686 auth_headers: dict[str, str],
1687 ) -> None:
1688 """GET /api/v1/musehub/repos/{unknown}/context/{ref} returns 404 for unknown repo."""
1689 response = await client.get(
1690 "/api/v1/musehub/repos/ghost-repo/context/deadbeef" + "0" * 56,
1691 headers=auth_headers,
1692 )
1693 assert response.status_code == 404
1694
1695
1696 @pytest.mark.anyio
1697 async def test_context_requires_auth(
1698 client: AsyncClient,
1699 db_session: AsyncSession,
1700 ) -> None:
1701 """GET /api/v1/musehub/repos/{repo_id}/context/{ref} returns 401 without auth."""
1702 repo_id = await _make_repo(db_session)
1703 response = await client.get(
1704 f"/api/v1/musehub/repos/{repo_id}/context/deadbeef" + "0" * 56,
1705 )
1706 assert response.status_code == 401
1707
1708
1709 @pytest.mark.anyio
1710 async def test_context_page_no_auth_required(
1711 client: AsyncClient,
1712 db_session: AsyncSession,
1713 ) -> None:
1714 """The context UI page must be accessible without a JWT (HTML shell handles auth)."""
1715 repo_id, commit_id = await _make_repo_with_commit(db_session)
1716 response = await client.get(f"/musehub/ui/testuser/jazz-context-test/context/{commit_id}")
1717 assert response.status_code != 401
1718 assert response.status_code == 200
1719
1720
1721 # ---------------------------------------------------------------------------
1722 # Context page additional tests
1723 # ---------------------------------------------------------------------------
1724
1725
1726 @pytest.mark.anyio
1727 async def test_context_page_contains_agent_explainer(
1728 client: AsyncClient,
1729 db_session: AsyncSession,
1730 ) -> None:
1731 """Context viewer page SSR: ref prefix and Musical Context heading appear in HTML.
1732
1733 The context page is now fully SSR — data is server-rendered rather than
1734 fetched client-side. The ref prefix must appear in the breadcrumb/badge
1735 and the Musical Context heading must be present.
1736 """
1737 repo_id, commit_id = await _make_repo_with_commit(db_session)
1738 response = await client.get(f"/musehub/ui/testuser/jazz-context-test/context/{commit_id}")
1739 assert response.status_code == 200
1740 body = response.text
1741 assert "Musical Context" in body
1742 assert commit_id[:8] in body
1743
1744
1745 # ---------------------------------------------------------------------------
1746 # Embed player route tests
1747 # ---------------------------------------------------------------------------
1748
1749
1750 @pytest.mark.anyio
1751 async def test_embed_page_renders(
1752 client: AsyncClient,
1753 db_session: AsyncSession,
1754 ) -> None:
1755 """GET /musehub/ui/{repo_id}/embed/{ref} returns 200 HTML."""
1756 repo_id = await _make_repo(db_session)
1757 ref = "abc1234567890abcdef"
1758 response = await client.get(f"/musehub/ui/testuser/test-beats/embed/{ref}")
1759 assert response.status_code == 200
1760 assert "text/html" in response.headers["content-type"]
1761
1762
1763 @pytest.mark.anyio
1764 async def test_embed_no_auth_required(
1765 client: AsyncClient,
1766 db_session: AsyncSession,
1767 ) -> None:
1768 """Embed page must be accessible without an Authorization header (public embedding)."""
1769 repo_id = await _make_repo(db_session)
1770 ref = "deadbeef1234"
1771 response = await client.get(f"/musehub/ui/testuser/test-beats/embed/{ref}")
1772 assert response.status_code != 401
1773 assert response.status_code == 200
1774
1775
1776 @pytest.mark.anyio
1777 async def test_embed_page_x_frame_options(
1778 client: AsyncClient,
1779 db_session: AsyncSession,
1780 ) -> None:
1781 """Embed page must set X-Frame-Options: ALLOWALL to permit cross-origin framing."""
1782 repo_id = await _make_repo(db_session)
1783 ref = "cafebabe1234"
1784 response = await client.get(f"/musehub/ui/testuser/test-beats/embed/{ref}")
1785 assert response.status_code == 200
1786 assert response.headers.get("x-frame-options") == "ALLOWALL"
1787
1788
1789 @pytest.mark.anyio
1790 async def test_embed_page_contains_player_ui(
1791 client: AsyncClient,
1792 db_session: AsyncSession,
1793 ) -> None:
1794 """Embed page HTML must contain player elements: play button, progress bar, and Muse Hub link."""
1795 repo_id = await _make_repo(db_session)
1796 ref = "feedface0123456789ab"
1797 response = await client.get(f"/musehub/ui/testuser/test-beats/embed/{ref}")
1798 assert response.status_code == 200
1799 body = response.text
1800 assert "play-btn" in body
1801 assert "progress-bar" in body
1802 assert "View on Muse Hub" in body
1803 assert "audio" in body
1804 assert repo_id in body
1805
1806 # ---------------------------------------------------------------------------
1807 # Groove check page and endpoint tests
1808
1809 # ---------------------------------------------------------------------------
1810
1811
1812 @pytest.mark.anyio
1813 async def test_groove_check_page_renders(
1814 client: AsyncClient,
1815 db_session: AsyncSession,
1816 ) -> None:
1817 """GET /musehub/ui/{repo_id}/groove-check returns 200 HTML without requiring a JWT."""
1818 repo_id = await _make_repo(db_session)
1819 response = await client.get("/musehub/ui/testuser/test-beats/groove-check")
1820 assert response.status_code == 200
1821 assert "text/html" in response.headers["content-type"]
1822 body = response.text
1823 assert "Muse Hub" in body
1824 assert "Groove Check" in body
1825
1826
1827 @pytest.mark.anyio
1828 async def test_credits_page_contains_json_ld_injection_slug_route(
1829 client: AsyncClient,
1830 db_session: AsyncSession,
1831 ) -> None:
1832 """Credits page embeds JSON-LD injection logic via slug route."""
1833 repo_id = await _make_repo(db_session)
1834 response = await client.get("/musehub/ui/testuser/test-beats/credits")
1835 assert response.status_code == 200
1836 body = response.text
1837 assert "application/ld+json" in body
1838 assert "schema.org" in body
1839 assert "MusicComposition" in body
1840
1841
1842 @pytest.mark.anyio
1843 async def test_credits_page_contains_sort_options_slug_route(
1844 client: AsyncClient,
1845 db_session: AsyncSession,
1846 ) -> None:
1847 """Credits page includes sort dropdown via slug route."""
1848 repo_id = await _make_repo(db_session)
1849 response = await client.get("/musehub/ui/testuser/test-beats/credits")
1850 assert response.status_code == 200
1851 body = response.text
1852 assert "Most prolific" in body
1853 assert "Most recent" in body
1854 assert "A" in body # "A – Z" option
1855
1856
1857 @pytest.mark.anyio
1858 async def test_credits_empty_state_message_in_page_slug_route(
1859 client: AsyncClient,
1860 db_session: AsyncSession,
1861 ) -> None:
1862 """Credits page JS includes empty-state message via slug route."""
1863 repo_id = await _make_repo(db_session)
1864 response = await client.get("/musehub/ui/testuser/test-beats/credits")
1865 assert response.status_code == 200
1866 body = response.text
1867 assert "muse session start" in body
1868
1869
1870 @pytest.mark.anyio
1871 async def test_credits_no_auth_required_slug_route(
1872 client: AsyncClient,
1873 db_session: AsyncSession,
1874 ) -> None:
1875 """Credits page must be accessible without an Authorization header via slug route."""
1876 repo_id = await _make_repo(db_session)
1877 response = await client.get("/musehub/ui/testuser/test-beats/credits")
1878 assert response.status_code == 200
1879 assert response.status_code != 401
1880
1881
1882 @pytest.mark.anyio
1883 async def test_credits_page_contains_avatar_functions(
1884 client: AsyncClient,
1885 db_session: AsyncSession,
1886 ) -> None:
1887 """Credits page includes avatarHsl and avatarCircle JS functions for contributor avatars."""
1888 await _make_repo(db_session)
1889 response = await client.get("/musehub/ui/testuser/test-beats/credits")
1890 assert response.status_code == 200
1891 body = response.text
1892 assert "avatarHsl" in body
1893 assert "avatarCircle" in body
1894 assert "border-radius:50%" in body
1895
1896
1897 @pytest.mark.anyio
1898 async def test_credits_page_contains_fetch_profile_function(
1899 client: AsyncClient,
1900 db_session: AsyncSession,
1901 ) -> None:
1902 """Credits page includes fetchProfile JS function that fetches contributor profiles in parallel."""
1903 await _make_repo(db_session)
1904 response = await client.get("/musehub/ui/testuser/test-beats/credits")
1905 assert response.status_code == 200
1906 body = response.text
1907 assert "fetchProfile" in body
1908 assert "Promise.all" in body
1909 assert "/users/" in body
1910
1911
1912 @pytest.mark.anyio
1913 async def test_credits_page_contains_profile_link_pattern(
1914 client: AsyncClient,
1915 db_session: AsyncSession,
1916 ) -> None:
1917 """Credits page links contributor names to their profile pages at /musehub/ui/users/{username}."""
1918 await _make_repo(db_session)
1919 response = await client.get("/musehub/ui/testuser/test-beats/credits")
1920 assert response.status_code == 200
1921 body = response.text
1922 assert "/musehub/ui/users/" in body
1923 assert "encodeURIComponent" in body
1924
1925
1926 @pytest.mark.anyio
1927 async def test_groove_check_page_no_auth_required(
1928 client: AsyncClient,
1929 db_session: AsyncSession,
1930 ) -> None:
1931 """Groove check UI page must be accessible without an Authorization header (HTML shell)."""
1932 repo_id = await _make_repo(db_session)
1933 response = await client.get("/musehub/ui/testuser/test-beats/groove-check")
1934 assert response.status_code != 401
1935 assert response.status_code == 200
1936
1937
1938 # ---------------------------------------------------------------------------
1939 # Object listing endpoint tests (JSON, authed)
1940 # ---------------------------------------------------------------------------
1941
1942
1943 @pytest.mark.anyio
1944 async def test_groove_check_page_contains_chart_js(
1945 client: AsyncClient,
1946 db_session: AsyncSession,
1947 ) -> None:
1948 """Groove check page embeds the SVG chart rendering JavaScript."""
1949 repo_id = await _make_repo(db_session)
1950 response = await client.get("/musehub/ui/testuser/test-beats/groove-check")
1951 assert response.status_code == 200
1952 body = response.text
1953 assert "renderGrooveChart" in body
1954 assert "grooveScore" in body
1955 assert "driftDelta" in body
1956
1957
1958 @pytest.mark.anyio
1959 async def test_groove_check_page_contains_status_badges(
1960 client: AsyncClient,
1961 db_session: AsyncSession,
1962 ) -> None:
1963 """Groove check page HTML includes OK / WARN / FAIL status badge rendering."""
1964 repo_id = await _make_repo(db_session)
1965 response = await client.get("/musehub/ui/testuser/test-beats/groove-check")
1966 assert response.status_code == 200
1967 body = response.text
1968 assert "statusBadge" in body
1969 assert "WARN" in body
1970 assert "FAIL" in body
1971
1972
1973 @pytest.mark.anyio
1974 async def test_groove_check_page_includes_token_form(
1975 client: AsyncClient,
1976 db_session: AsyncSession,
1977 ) -> None:
1978 """Groove check page embeds the JWT token input form so visitors can authenticate."""
1979 repo_id = await _make_repo(db_session)
1980 response = await client.get("/musehub/ui/testuser/test-beats/groove-check")
1981 assert response.status_code == 200
1982 body = response.text
1983 assert "token-form" in body
1984 assert "token-input" in body
1985
1986
1987 @pytest.mark.anyio
1988 async def test_groove_check_endpoint_returns_json(
1989 client: AsyncClient,
1990 db_session: AsyncSession,
1991 auth_headers: dict[str, str],
1992 ) -> None:
1993 """GET /api/v1/musehub/repos/{repo_id}/groove-check returns JSON with required fields."""
1994 repo_id = await _make_repo(db_session)
1995 response = await client.get(
1996 f"/api/v1/musehub/repos/{repo_id}/groove-check",
1997 headers=auth_headers,
1998 )
1999 assert response.status_code == 200
2000 body = response.json()
2001 assert "commitRange" in body
2002 assert "threshold" in body
2003 assert "totalCommits" in body
2004 assert "flaggedCommits" in body
2005 assert "worstCommit" in body
2006 assert "entries" in body
2007 assert isinstance(body["entries"], list)
2008
2009
2010 @pytest.mark.anyio
2011 async def test_graph_no_auth_required(
2012 client: AsyncClient,
2013 db_session: AsyncSession,
2014 ) -> None:
2015 """Graph page must be accessible without an Authorization header (HTML shell)."""
2016 repo_id = await _make_repo(db_session)
2017 response = await client.get("/musehub/ui/testuser/test-beats/graph")
2018 assert response.status_code == 200
2019 assert response.status_code != 401
2020
2021
2022 async def test_groove_check_endpoint_entries_have_required_fields(
2023 client: AsyncClient,
2024 db_session: AsyncSession,
2025 auth_headers: dict[str, str],
2026 ) -> None:
2027 """Groove check entries each contain commit, grooveScore, driftDelta, and status."""
2028 repo_id = await _make_repo(db_session)
2029 response = await client.get(
2030 f"/api/v1/musehub/repos/{repo_id}/groove-check?limit=5",
2031 headers=auth_headers,
2032 )
2033 assert response.status_code == 200
2034 body = response.json()
2035 assert body["totalCommits"] > 0
2036 entry = body["entries"][0]
2037 assert "commit" in entry
2038 assert "grooveScore" in entry
2039 assert "driftDelta" in entry
2040 assert "status" in entry
2041 assert entry["status"] in ("OK", "WARN", "FAIL")
2042 assert "track" in entry
2043 assert "midiFiles" in entry
2044
2045
2046 @pytest.mark.anyio
2047 async def test_groove_check_endpoint_requires_auth(
2048 client: AsyncClient,
2049 db_session: AsyncSession,
2050 ) -> None:
2051 """GET /api/v1/musehub/repos/{repo_id}/groove-check returns 401 without auth."""
2052 repo_id = await _make_repo(db_session)
2053 response = await client.get(f"/api/v1/musehub/repos/{repo_id}/groove-check")
2054 assert response.status_code == 401
2055
2056
2057 @pytest.mark.anyio
2058 async def test_groove_check_endpoint_404_for_unknown_repo(
2059 client: AsyncClient,
2060 db_session: AsyncSession,
2061 auth_headers: dict[str, str],
2062 ) -> None:
2063 """GET /api/v1/musehub/repos/{unknown}/groove-check returns 404."""
2064 response = await client.get(
2065 "/api/v1/musehub/repos/does-not-exist/groove-check",
2066 headers=auth_headers,
2067 )
2068 assert response.status_code == 404
2069
2070
2071 @pytest.mark.anyio
2072 async def test_groove_check_endpoint_respects_limit(
2073 client: AsyncClient,
2074 db_session: AsyncSession,
2075 auth_headers: dict[str, str],
2076 ) -> None:
2077 """Groove check endpoint returns at most ``limit`` entries."""
2078 repo_id = await _make_repo(db_session)
2079 response = await client.get(
2080 f"/api/v1/musehub/repos/{repo_id}/groove-check?limit=3",
2081 headers=auth_headers,
2082 )
2083 assert response.status_code == 200
2084 body = response.json()
2085 assert body["totalCommits"] <= 3
2086 assert len(body["entries"]) <= 3
2087
2088
2089 @pytest.mark.anyio
2090 async def test_groove_check_endpoint_custom_threshold(
2091 client: AsyncClient,
2092 db_session: AsyncSession,
2093 auth_headers: dict[str, str],
2094 ) -> None:
2095 """Groove check endpoint accepts a custom threshold parameter."""
2096 repo_id = await _make_repo(db_session)
2097 response = await client.get(
2098 f"/api/v1/musehub/repos/{repo_id}/groove-check?threshold=0.05",
2099 headers=auth_headers,
2100 )
2101 assert response.status_code == 200
2102 body = response.json()
2103 assert abs(body["threshold"] - 0.05) < 1e-9
2104
2105
2106 @pytest.mark.anyio
2107 async def test_repo_page_contains_groove_check_link(
2108 client: AsyncClient,
2109 db_session: AsyncSession,
2110 ) -> None:
2111 """Repo landing page navigation includes a Groove Check link."""
2112 repo_id = await _make_repo(db_session)
2113 response = await client.get("/musehub/ui/testuser/test-beats")
2114 assert response.status_code == 200
2115 body = response.text
2116 assert "groove-check" in body
2117
2118
2119 # ---------------------------------------------------------------------------
2120 # User profile page tests (— pre-existing from dev, fixed here)
2121 # ---------------------------------------------------------------------------
2122
2123
2124 @pytest.mark.anyio
2125 async def test_profile_page_renders(
2126 client: AsyncClient,
2127 db_session: AsyncSession,
2128 ) -> None:
2129 """GET /musehub/ui/users/{username} returns 200 HTML for a known profile."""
2130 await _make_profile(db_session, "rockstar")
2131 response = await client.get("/musehub/ui/users/rockstar")
2132 assert response.status_code == 200
2133 assert "text/html" in response.headers["content-type"]
2134 body = response.text
2135 assert "Muse Hub" in body
2136 assert "@rockstar" in body
2137 # Contribution graph JS must be present
2138 assert "contributionGraph" in body or "contrib-graph" in body
2139
2140
2141 @pytest.mark.anyio
2142 async def test_profile_no_auth_required_ui(
2143 client: AsyncClient,
2144 db_session: AsyncSession,
2145 ) -> None:
2146 """Profile UI page is publicly accessible without a JWT (returns 200, not 401)."""
2147 await _make_profile(db_session, "public-user")
2148 response = await client.get("/musehub/ui/users/public-user")
2149 assert response.status_code == 200
2150 assert response.status_code != 401
2151
2152
2153 @pytest.mark.anyio
2154 async def test_profile_unknown_user_404(
2155 client: AsyncClient,
2156 db_session: AsyncSession,
2157 ) -> None:
2158 """GET /api/v1/musehub/users/{unknown} returns 404 for a non-existent profile."""
2159 response = await client.get("/api/v1/musehub/users/does-not-exist-xyz")
2160 assert response.status_code == 404
2161
2162
2163 @pytest.mark.anyio
2164 async def test_profile_json_response(
2165 client: AsyncClient,
2166 db_session: AsyncSession,
2167 ) -> None:
2168 """GET /api/v1/musehub/users/{username} returns a valid JSON profile with required fields."""
2169 await _make_profile(db_session, "jazzmaster")
2170 response = await client.get("/api/v1/musehub/users/jazzmaster")
2171 assert response.status_code == 200
2172 data = response.json()
2173 assert data["username"] == "jazzmaster"
2174 assert "repos" in data
2175 assert "contributionGraph" in data
2176 assert "sessionCredits" in data
2177 assert isinstance(data["sessionCredits"], int)
2178 assert isinstance(data["contributionGraph"], list)
2179
2180
2181 @pytest.mark.anyio
2182 async def test_profile_lists_repos(
2183 client: AsyncClient,
2184 db_session: AsyncSession,
2185 ) -> None:
2186 """GET /api/v1/musehub/users/{username} includes public repos in the response."""
2187 await _make_profile(db_session, "beatmaker")
2188 repo_id = await _make_public_repo(db_session)
2189 response = await client.get("/api/v1/musehub/users/beatmaker")
2190 assert response.status_code == 200
2191 data = response.json()
2192 repo_ids = [r["repoId"] for r in data["repos"]]
2193 assert repo_id in repo_ids
2194
2195
2196 @pytest.mark.anyio
2197 async def test_profile_create_and_update(
2198 client: AsyncClient,
2199 db_session: AsyncSession,
2200 auth_headers: dict[str, str],
2201 ) -> None:
2202 """POST /api/v1/musehub/users creates a profile; PUT updates it."""
2203 # Create profile
2204 resp = await client.post(
2205 "/api/v1/musehub/users",
2206 json={"username": "newartist", "bio": "Initial bio"},
2207 headers=auth_headers,
2208 )
2209 assert resp.status_code == 201
2210 data = resp.json()
2211 assert data["username"] == "newartist"
2212 assert data["bio"] == "Initial bio"
2213
2214 # Update profile
2215 resp2 = await client.put(
2216 "/api/v1/musehub/users/newartist",
2217 json={"bio": "Updated bio"},
2218 headers=auth_headers,
2219 )
2220 assert resp2.status_code == 200
2221 assert resp2.json()["bio"] == "Updated bio"
2222
2223
2224 @pytest.mark.anyio
2225 async def test_profile_create_duplicate_username_409(
2226 client: AsyncClient,
2227 db_session: AsyncSession,
2228 auth_headers: dict[str, str],
2229 ) -> None:
2230 """POST /api/v1/musehub/users returns 409 when username is already taken."""
2231 await _make_profile(db_session, "takenname")
2232 resp = await client.post(
2233 "/api/v1/musehub/users",
2234 json={"username": "takenname"},
2235 headers=auth_headers,
2236 )
2237 assert resp.status_code == 409
2238
2239
2240 @pytest.mark.anyio
2241 async def test_profile_update_403_for_wrong_owner(
2242 client: AsyncClient,
2243 db_session: AsyncSession,
2244 auth_headers: dict[str, str],
2245 ) -> None:
2246 """PUT /api/v1/musehub/users/{username} returns 403 when caller doesn't own the profile."""
2247 # Create a profile owned by a DIFFERENT user
2248 other_profile = MusehubProfile(
2249 user_id="different-user-id-999",
2250 username="someoneelse",
2251 bio="not yours",
2252 pinned_repo_ids=[],
2253 )
2254 db_session.add(other_profile)
2255 await db_session.commit()
2256
2257 resp = await client.put(
2258 "/api/v1/musehub/users/someoneelse",
2259 json={"bio": "hijacked"},
2260 headers=auth_headers,
2261 )
2262 assert resp.status_code == 403
2263
2264
2265 @pytest.mark.anyio
2266 async def test_profile_page_unknown_user_renders_404_inline(
2267 client: AsyncClient,
2268 db_session: AsyncSession,
2269 ) -> None:
2270 """GET /musehub/ui/users/{unknown} returns 200 HTML (JS renders 404 inline)."""
2271 response = await client.get("/musehub/ui/users/ghost-user-xyz")
2272 # The HTML shell always returns 200 — the JS fetches and handles the API 404
2273 assert response.status_code == 200
2274 assert "text/html" in response.headers["content-type"]
2275
2276
2277 # ---------------------------------------------------------------------------
2278 # Forked repos endpoint tests
2279 # ---------------------------------------------------------------------------
2280
2281
2282 @pytest.mark.anyio
2283 async def test_profile_forked_repos_empty_list(
2284 client: AsyncClient,
2285 db_session: AsyncSession,
2286 ) -> None:
2287 """GET /api/v1/musehub/users/{username}/forks returns empty list when user has no forks."""
2288 await _make_profile(db_session, "freshuser")
2289 response = await client.get("/api/v1/musehub/users/freshuser/forks")
2290 assert response.status_code == 200
2291 data = response.json()
2292 assert data["forks"] == []
2293 assert data["total"] == 0
2294
2295
2296 @pytest.mark.anyio
2297 async def test_profile_forked_repos_returns_forks(
2298 client: AsyncClient,
2299 db_session: AsyncSession,
2300 ) -> None:
2301 """GET /api/v1/musehub/users/{username}/forks returns forked repos with source attribution."""
2302 await _make_profile(db_session, "forkuser")
2303
2304 # Seed a source repo owned by another user
2305 source = MusehubRepo(
2306 name="original-track",
2307 owner="original-owner",
2308 slug="original-track",
2309 visibility="public",
2310 owner_user_id="original-owner-id",
2311 )
2312 db_session.add(source)
2313 await db_session.commit()
2314 await db_session.refresh(source)
2315
2316 # Seed the fork repo owned by forkuser
2317 fork_repo = MusehubRepo(
2318 name="original-track",
2319 owner="forkuser",
2320 slug="original-track",
2321 visibility="public",
2322 owner_user_id=_TEST_USER_ID,
2323 )
2324 db_session.add(fork_repo)
2325 await db_session.commit()
2326 await db_session.refresh(fork_repo)
2327
2328 # Seed the fork relationship
2329 fork = MusehubFork(
2330 source_repo_id=source.repo_id,
2331 fork_repo_id=fork_repo.repo_id,
2332 forked_by="forkuser",
2333 )
2334 db_session.add(fork)
2335 await db_session.commit()
2336
2337 response = await client.get("/api/v1/musehub/users/forkuser/forks")
2338 assert response.status_code == 200
2339 data = response.json()
2340 assert data["total"] == 1
2341 assert len(data["forks"]) == 1
2342 entry = data["forks"][0]
2343 assert entry["sourceOwner"] == "original-owner"
2344 assert entry["sourceSlug"] == "original-track"
2345 assert entry["forkRepo"]["owner"] == "forkuser"
2346 assert entry["forkRepo"]["slug"] == "original-track"
2347 assert "forkId" in entry
2348 assert "forkedAt" in entry
2349
2350
2351 @pytest.mark.anyio
2352 async def test_profile_forked_repos_404_for_unknown_user(
2353 client: AsyncClient,
2354 db_session: AsyncSession,
2355 ) -> None:
2356 """GET /api/v1/musehub/users/{unknown}/forks returns 404 when user doesn't exist."""
2357 response = await client.get("/api/v1/musehub/users/ghost-no-profile/forks")
2358 assert response.status_code == 404
2359
2360
2361 @pytest.mark.anyio
2362 async def test_profile_forked_repos_no_auth_required(
2363 client: AsyncClient,
2364 db_session: AsyncSession,
2365 ) -> None:
2366 """GET /api/v1/musehub/users/{username}/forks is publicly accessible without a JWT."""
2367 await _make_profile(db_session, "public-forkuser")
2368 response = await client.get("/api/v1/musehub/users/public-forkuser/forks")
2369 assert response.status_code == 200
2370 assert response.status_code != 401
2371
2372
2373 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
2374 @pytest.mark.anyio
2375 async def test_profile_page_has_forked_section_js(
2376 client: AsyncClient,
2377 db_session: AsyncSession,
2378 ) -> None:
2379 """Profile HTML page includes the forked repos JS (loadForkedRepos, forked-section)."""
2380 await _make_profile(db_session, "jsforkuser")
2381 response = await client.get("/musehub/ui/users/jsforkuser")
2382 assert response.status_code == 200
2383 body = response.text
2384 assert "loadForkedRepos" in body
2385 assert "forked-section" in body
2386 assert "API_FORKS" in body
2387 assert "forked from" in body
2388
2389
2390 # ---------------------------------------------------------------------------
2391 # Starred repos tab
2392 # ---------------------------------------------------------------------------
2393
2394
2395 @pytest.mark.anyio
2396 async def test_profile_starred_repos_empty_list(
2397 client: AsyncClient,
2398 db_session: AsyncSession,
2399 ) -> None:
2400 """GET /api/v1/musehub/users/{username}/starred returns empty list when user has no stars."""
2401 await _make_profile(db_session, "freshstaruser")
2402 response = await client.get("/api/v1/musehub/users/freshstaruser/starred")
2403 assert response.status_code == 200
2404 data = response.json()
2405 assert data["starred"] == []
2406 assert data["total"] == 0
2407
2408
2409 @pytest.mark.anyio
2410 async def test_profile_starred_repos_returns_starred(
2411 client: AsyncClient,
2412 db_session: AsyncSession,
2413 ) -> None:
2414 """GET /api/v1/musehub/users/{username}/starred returns starred repos with full metadata."""
2415 await _make_profile(db_session, "stargazeruser")
2416
2417 repo = MusehubRepo(
2418 name="awesome-groove",
2419 owner="someartist",
2420 slug="awesome-groove",
2421 visibility="public",
2422 owner_user_id="someartist-id",
2423 description="A great groove",
2424 key_signature="C major",
2425 tempo_bpm=120,
2426 )
2427 db_session.add(repo)
2428 await db_session.commit()
2429 await db_session.refresh(repo)
2430
2431 star = MusehubStar(
2432 repo_id=repo.repo_id,
2433 user_id=_TEST_USER_ID,
2434 )
2435 db_session.add(star)
2436 await db_session.commit()
2437
2438 response = await client.get("/api/v1/musehub/users/stargazeruser/starred")
2439 assert response.status_code == 200
2440 data = response.json()
2441 assert data["total"] == 1
2442 assert len(data["starred"]) == 1
2443 entry = data["starred"][0]
2444 assert entry["repo"]["owner"] == "someartist"
2445 assert entry["repo"]["slug"] == "awesome-groove"
2446 assert entry["repo"]["description"] == "A great groove"
2447 assert "starId" in entry
2448 assert "starredAt" in entry
2449
2450
2451 @pytest.mark.anyio
2452 async def test_profile_starred_repos_404_for_unknown_user(
2453 client: AsyncClient,
2454 db_session: AsyncSession,
2455 ) -> None:
2456 """GET /api/v1/musehub/users/{unknown}/starred returns 404 when user doesn't exist."""
2457 response = await client.get("/api/v1/musehub/users/ghost-no-star-profile/starred")
2458 assert response.status_code == 404
2459
2460
2461 @pytest.mark.anyio
2462 async def test_profile_starred_repos_no_auth_required(
2463 client: AsyncClient,
2464 db_session: AsyncSession,
2465 ) -> None:
2466 """GET /api/v1/musehub/users/{username}/starred is publicly accessible without a JWT."""
2467 await _make_profile(db_session, "public-staruser")
2468 response = await client.get("/api/v1/musehub/users/public-staruser/starred")
2469 assert response.status_code == 200
2470 assert response.status_code != 401
2471
2472
2473 @pytest.mark.anyio
2474 async def test_profile_starred_repos_ordered_newest_first(
2475 client: AsyncClient,
2476 db_session: AsyncSession,
2477 ) -> None:
2478 """GET /api/v1/musehub/users/{username}/starred returns stars newest first."""
2479 from datetime import timezone
2480
2481 await _make_profile(db_session, "multistaruser")
2482
2483 repo_a = MusehubRepo(
2484 name="track-alpha", owner="artist-a", slug="track-alpha",
2485 visibility="public", owner_user_id="artist-a-id",
2486 )
2487 repo_b = MusehubRepo(
2488 name="track-beta", owner="artist-b", slug="track-beta",
2489 visibility="public", owner_user_id="artist-b-id",
2490 )
2491 db_session.add_all([repo_a, repo_b])
2492 await db_session.commit()
2493 await db_session.refresh(repo_a)
2494 await db_session.refresh(repo_b)
2495
2496 import datetime as dt
2497 star_a = MusehubStar(
2498 repo_id=repo_a.repo_id,
2499 user_id=_TEST_USER_ID,
2500 created_at=dt.datetime(2024, 1, 1, tzinfo=timezone.utc),
2501 )
2502 star_b = MusehubStar(
2503 repo_id=repo_b.repo_id,
2504 user_id=_TEST_USER_ID,
2505 created_at=dt.datetime(2024, 6, 1, tzinfo=timezone.utc),
2506 )
2507 db_session.add_all([star_a, star_b])
2508 await db_session.commit()
2509
2510 response = await client.get("/api/v1/musehub/users/multistaruser/starred")
2511 assert response.status_code == 200
2512 data = response.json()
2513 assert data["total"] == 2
2514 # newest first: star_b (June) before star_a (January)
2515 assert data["starred"][0]["repo"]["slug"] == "track-beta"
2516 assert data["starred"][1]["repo"]["slug"] == "track-alpha"
2517
2518
2519 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
2520 @pytest.mark.anyio
2521 async def test_profile_page_has_starred_section_js(
2522 client: AsyncClient,
2523 db_session: AsyncSession,
2524 ) -> None:
2525 """Profile HTML page includes the starred repos JS (loadStarredRepos, starred-section)."""
2526 await _make_profile(db_session, "jsstaruser")
2527 response = await client.get("/musehub/ui/users/jsstaruser")
2528 assert response.status_code == 200
2529 body = response.text
2530 assert "loadStarredRepos" in body
2531 assert "starred-section" in body
2532 assert "API_STARRED" in body
2533 assert "starredRepoCardHtml" in body
2534
2535
2536 # ---------------------------------------------------------------------------
2537 # Watched repos tab
2538 # ---------------------------------------------------------------------------
2539
2540
2541 @pytest.mark.anyio
2542 async def test_profile_watched_repos_empty_list(
2543 client: AsyncClient,
2544 db_session: AsyncSession,
2545 ) -> None:
2546 """GET /api/v1/musehub/users/{username}/watched returns empty list when user watches nothing."""
2547 await _make_profile(db_session, "freshwatchuser")
2548 response = await client.get("/api/v1/musehub/users/freshwatchuser/watched")
2549 assert response.status_code == 200
2550 data = response.json()
2551 assert data["watched"] == []
2552 assert data["total"] == 0
2553
2554
2555 @pytest.mark.anyio
2556 async def test_profile_watched_repos_returns_watched(
2557 client: AsyncClient,
2558 db_session: AsyncSession,
2559 ) -> None:
2560 """GET /api/v1/musehub/users/{username}/watched returns watched repos with full metadata."""
2561 await _make_profile(db_session, "watcheruser")
2562
2563 repo = MusehubRepo(
2564 name="cool-composition",
2565 owner="composer",
2566 slug="cool-composition",
2567 visibility="public",
2568 owner_user_id="composer-id",
2569 description="A cool composition",
2570 key_signature="G minor",
2571 tempo_bpm=90,
2572 )
2573 db_session.add(repo)
2574 await db_session.commit()
2575 await db_session.refresh(repo)
2576
2577 watch = MusehubWatch(
2578 repo_id=repo.repo_id,
2579 user_id=_TEST_USER_ID,
2580 )
2581 db_session.add(watch)
2582 await db_session.commit()
2583
2584 response = await client.get("/api/v1/musehub/users/watcheruser/watched")
2585 assert response.status_code == 200
2586 data = response.json()
2587 assert data["total"] == 1
2588 assert len(data["watched"]) == 1
2589 entry = data["watched"][0]
2590 assert entry["repo"]["owner"] == "composer"
2591 assert entry["repo"]["slug"] == "cool-composition"
2592 assert entry["repo"]["description"] == "A cool composition"
2593 assert "watchId" in entry
2594 assert "watchedAt" in entry
2595
2596
2597 @pytest.mark.anyio
2598 async def test_profile_watched_repos_404_for_unknown_user(
2599 client: AsyncClient,
2600 db_session: AsyncSession,
2601 ) -> None:
2602 """GET /api/v1/musehub/users/{unknown}/watched returns 404 when user doesn't exist."""
2603 response = await client.get("/api/v1/musehub/users/ghost-no-watch-profile/watched")
2604 assert response.status_code == 404
2605
2606
2607 @pytest.mark.anyio
2608 async def test_profile_watched_repos_no_auth_required(
2609 client: AsyncClient,
2610 db_session: AsyncSession,
2611 ) -> None:
2612 """GET /api/v1/musehub/users/{username}/watched is publicly accessible without a JWT."""
2613 await _make_profile(db_session, "public-watchuser")
2614 response = await client.get("/api/v1/musehub/users/public-watchuser/watched")
2615 assert response.status_code == 200
2616 assert response.status_code != 401
2617
2618
2619 @pytest.mark.anyio
2620 async def test_profile_watched_repos_ordered_newest_first(
2621 client: AsyncClient,
2622 db_session: AsyncSession,
2623 ) -> None:
2624 """GET /api/v1/musehub/users/{username}/watched returns watches newest first."""
2625 import datetime as dt
2626 from datetime import timezone
2627
2628 await _make_profile(db_session, "multiwatchuser")
2629
2630 repo_a = MusehubRepo(
2631 name="song-alpha", owner="band-a", slug="song-alpha",
2632 visibility="public", owner_user_id="band-a-id",
2633 )
2634 repo_b = MusehubRepo(
2635 name="song-beta", owner="band-b", slug="song-beta",
2636 visibility="public", owner_user_id="band-b-id",
2637 )
2638 db_session.add_all([repo_a, repo_b])
2639 await db_session.commit()
2640 await db_session.refresh(repo_a)
2641 await db_session.refresh(repo_b)
2642
2643 watch_a = MusehubWatch(
2644 repo_id=repo_a.repo_id,
2645 user_id=_TEST_USER_ID,
2646 created_at=dt.datetime(2024, 1, 1, tzinfo=timezone.utc),
2647 )
2648 watch_b = MusehubWatch(
2649 repo_id=repo_b.repo_id,
2650 user_id=_TEST_USER_ID,
2651 created_at=dt.datetime(2024, 6, 1, tzinfo=timezone.utc),
2652 )
2653 db_session.add_all([watch_a, watch_b])
2654 await db_session.commit()
2655
2656 response = await client.get("/api/v1/musehub/users/multiwatchuser/watched")
2657 assert response.status_code == 200
2658 data = response.json()
2659 assert data["total"] == 2
2660 # newest first: watch_b (June) before watch_a (January)
2661 assert data["watched"][0]["repo"]["slug"] == "song-beta"
2662 assert data["watched"][1]["repo"]["slug"] == "song-alpha"
2663
2664
2665 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
2666 @pytest.mark.anyio
2667 async def test_profile_page_has_watched_section_js(
2668 client: AsyncClient,
2669 db_session: AsyncSession,
2670 ) -> None:
2671 """Profile HTML page includes the watched repos JS (loadWatchedRepos, watched-section)."""
2672 await _make_profile(db_session, "jswatchuser")
2673 response = await client.get("/musehub/ui/users/jswatchuser")
2674 assert response.status_code == 200
2675 body = response.text
2676 assert "loadWatchedRepos" in body
2677 assert "watched-section" in body
2678 assert "API_WATCHED" in body
2679
2680
2681 @pytest.mark.anyio
2682 async def test_timeline_page_renders(
2683 client: AsyncClient,
2684 db_session: AsyncSession,
2685 ) -> None:
2686 """GET /musehub/ui/{repo_id}/timeline returns 200 HTML without requiring a JWT."""
2687 repo_id = await _make_repo(db_session)
2688 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2689 assert response.status_code == 200
2690 assert "text/html" in response.headers["content-type"]
2691 body = response.text
2692 assert "Muse Hub" in body
2693 assert "timeline" in body.lower()
2694 assert repo_id[:8] in body
2695
2696
2697 @pytest.mark.anyio
2698 async def test_timeline_page_no_auth_required(
2699 client: AsyncClient,
2700 db_session: AsyncSession,
2701 ) -> None:
2702 """Timeline UI route must be accessible without an Authorization header."""
2703 repo_id = await _make_repo(db_session)
2704 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2705 assert response.status_code != 401
2706 assert response.status_code == 200
2707
2708
2709 @pytest.mark.anyio
2710 async def test_timeline_page_contains_layer_controls(
2711 client: AsyncClient,
2712 db_session: AsyncSession,
2713 ) -> None:
2714 """Timeline page embeds toggleable layer controls for all four layers."""
2715 repo_id = await _make_repo(db_session)
2716 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2717 assert response.status_code == 200
2718 body = response.text
2719 assert "Commits" in body
2720 assert "Emotion" in body
2721 assert "Sections" in body
2722 assert "Tracks" in body
2723
2724
2725 @pytest.mark.anyio
2726 async def test_timeline_page_contains_zoom_controls(
2727 client: AsyncClient,
2728 db_session: AsyncSession,
2729 ) -> None:
2730 """Timeline page embeds day/week/month/all zoom buttons."""
2731 repo_id = await _make_repo(db_session)
2732 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2733 assert response.status_code == 200
2734 body = response.text
2735 assert "Day" in body
2736 assert "Week" in body
2737 assert "Month" in body
2738 assert "All" in body
2739
2740
2741 @pytest.mark.anyio
2742 async def test_timeline_page_includes_token_form(
2743 client: AsyncClient,
2744 db_session: AsyncSession,
2745 ) -> None:
2746 """Timeline page includes the JWT token input form."""
2747 repo_id = await _make_repo(db_session)
2748 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2749 assert response.status_code == 200
2750 body = response.text
2751 assert "musehub/static/musehub.js" in body
2752 assert "token-form" in body
2753
2754
2755 @pytest.mark.anyio
2756 async def test_timeline_page_contains_overlay_toggles(
2757 client: AsyncClient,
2758 db_session: AsyncSession,
2759 ) -> None:
2760 """Timeline page must include Sessions, PRs, and Releases layer toggle checkboxes.
2761
2762 Regression test — before this fix the timeline had no
2763 overlay markers for repo lifecycle events (sessions, PR merges, releases).
2764 """
2765 await _make_repo(db_session)
2766 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2767 assert response.status_code == 200
2768 body = response.text
2769 # All three new overlay toggle labels must be present.
2770 assert "Sessions" in body
2771 assert "PRs" in body
2772 assert "Releases" in body
2773
2774
2775 @pytest.mark.anyio
2776 async def test_timeline_page_overlay_js_variables(
2777 client: AsyncClient,
2778 db_session: AsyncSession,
2779 ) -> None:
2780 """Timeline page script must initialise sessions, mergedPRs, and releases arrays.
2781
2782 Verifies that the overlay data arrays and the layer toggle state are wired
2783 up in the page's inline script so the renderer can draw markers.
2784 """
2785 await _make_repo(db_session)
2786 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2787 assert response.status_code == 200
2788 body = response.text
2789 # State variables for overlay data must be declared.
2790 assert "let sessions" in body
2791 assert "let mergedPRs" in body
2792 assert "let releases" in body
2793 # Layer toggle state must include the three new keys.
2794 assert "sessions: true" in body
2795 assert "prs: true" in body
2796 assert "releases: true" in body
2797
2798
2799 @pytest.mark.anyio
2800 async def test_timeline_page_overlay_fetch_calls(
2801 client: AsyncClient,
2802 db_session: AsyncSession,
2803 ) -> None:
2804 """Timeline page must issue API calls for sessions, merged PRs, and releases.
2805
2806 Checks that the inline script calls the correct API paths so the browser
2807 will fetch overlay data when the page loads.
2808 """
2809 await _make_repo(db_session)
2810 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2811 assert response.status_code == 200
2812 body = response.text
2813 assert "/sessions" in body
2814 assert "state=merged" in body
2815 assert "/releases" in body
2816
2817
2818 @pytest.mark.anyio
2819 async def test_timeline_page_overlay_legend(
2820 client: AsyncClient,
2821 db_session: AsyncSession,
2822 ) -> None:
2823 """Timeline page legend must describe the three new overlay marker types."""
2824 await _make_repo(db_session)
2825 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2826 assert response.status_code == 200
2827 body = response.text
2828 # Colour labels in the legend.
2829 assert "teal" in body.lower()
2830 assert "gold" in body.lower()
2831
2832
2833 @pytest.mark.anyio
2834 async def test_timeline_pr_markers_use_merged_at_for_positioning(
2835 client: AsyncClient,
2836 db_session: AsyncSession,
2837 ) -> None:
2838 """Timeline JS must use pr.mergedAt (falling back to pr.createdAt) for marker X position.
2839
2840 Regression test: previously the overlay always used
2841 createdAt, which positioned merge markers at PR *open* time instead of
2842 merge time, sometimes off by days or weeks.
2843 """
2844 await _make_repo(db_session)
2845 response = await client.get("/musehub/ui/testuser/test-beats/timeline")
2846 assert response.status_code == 200
2847 body = response.text
2848 # The fix: JS should reference pr.mergedAt and fall back to pr.createdAt.
2849 assert "pr.mergedAt" in body
2850 assert "pr.createdAt" in body
2851 # The tooltip timestamp must also use mergedAt.
2852 assert "new Date(pr.mergedAt)" in body
2853
2854
2855 @pytest.mark.anyio
2856 async def test_pr_response_includes_merged_at_after_merge(
2857 client: AsyncClient,
2858 db_session: AsyncSession,
2859 auth_headers: dict[str, str],
2860 ) -> None:
2861 """PRResponse must expose merged_at set to the merge timestamp (not None) after merge.
2862
2863 Regression test: before this fix merged_at was absent from
2864 PRResponse, forcing the timeline to fall back to createdAt.
2865 """
2866 from datetime import datetime, timezone
2867
2868 from musehub.services import musehub_pull_requests, musehub_repository
2869
2870 repo_id = await _make_repo(db_session)
2871
2872 # Create two branches with commits so the merge can proceed.
2873 import uuid as _uuid
2874
2875 from musehub.db import musehub_models as dbm
2876
2877 commit_a_id = _uuid.uuid4().hex
2878 commit_main_id = _uuid.uuid4().hex
2879
2880 commit_a = dbm.MusehubCommit(
2881 commit_id=commit_a_id,
2882 repo_id=repo_id,
2883 branch="feat/test-merge",
2884 parent_ids=[],
2885 message="test commit on feature branch",
2886 author="tester",
2887 timestamp=datetime.now(timezone.utc),
2888 )
2889 branch_a = dbm.MusehubBranch(
2890 repo_id=repo_id, name="feat/test-merge", head_commit_id=commit_a_id
2891 )
2892 db_session.add(commit_a)
2893 db_session.add(branch_a)
2894
2895 commit_main = dbm.MusehubCommit(
2896 commit_id=commit_main_id,
2897 repo_id=repo_id,
2898 branch="main",
2899 parent_ids=[],
2900 message="initial commit on main",
2901 author="tester",
2902 timestamp=datetime.now(timezone.utc),
2903 )
2904 branch_main = dbm.MusehubBranch(
2905 repo_id=repo_id, name="main", head_commit_id=commit_main_id
2906 )
2907 db_session.add(commit_main)
2908 db_session.add(branch_main)
2909 await db_session.flush()
2910
2911 pr = await musehub_pull_requests.create_pr(
2912 db_session,
2913 repo_id=repo_id,
2914 title="Test merge PR",
2915 from_branch="feat/test-merge",
2916 to_branch="main",
2917 body="",
2918 author="tester",
2919 )
2920 await db_session.flush()
2921
2922 before_merge = datetime.now(timezone.utc)
2923 merged_pr = await musehub_pull_requests.merge_pr(
2924 db_session, repo_id, pr.pr_id, merge_strategy="merge_commit"
2925 )
2926 after_merge = datetime.now(timezone.utc)
2927
2928 assert merged_pr.merged_at is not None, "merged_at must be set after merge"
2929 # merged_at must be a timezone-aware datetime between before and after the merge call.
2930 merged_at = merged_pr.merged_at
2931 if merged_at.tzinfo is None:
2932 merged_at = merged_at.replace(tzinfo=timezone.utc)
2933 assert before_merge <= merged_at <= after_merge, (
2934 f"merged_at {merged_at} is outside the expected range [{before_merge}, {after_merge}]"
2935 )
2936 assert merged_pr.state == "merged"
2937
2938
2939 # ---------------------------------------------------------------------------
2940 # Embed player route tests
2941 # ---------------------------------------------------------------------------
2942
2943
2944 _UTC = timezone.utc
2945
2946
2947 @pytest.mark.anyio
2948 async def test_graph_page_contains_dag_js(
2949 client: AsyncClient,
2950 db_session: AsyncSession,
2951 ) -> None:
2952 """Graph page embeds the client-side DAG renderer JavaScript."""
2953 repo_id = await _make_repo(db_session)
2954 response = await client.get("/musehub/ui/testuser/test-beats/graph")
2955 assert response.status_code == 200
2956 body = response.text
2957 assert "renderGraph" in body
2958 assert "dag-viewport" in body
2959 assert "dag-svg" in body
2960
2961
2962 @pytest.mark.anyio
2963 async def test_graph_page_contains_session_ring_js(
2964 client: AsyncClient,
2965 db_session: AsyncSession,
2966 ) -> None:
2967 """Graph page embeds session-marker and reaction-count JavaScript.
2968
2969 Regression guard: ensures the template contains the three
2970 new client-side components added by this feature — SESSION_RING_COLOR (the
2971 teal ring constant), buildSessionMap (commit→session index builder), and
2972 fetchReactions (batch reaction fetcher). A missing symbol means the graph
2973 will silently render with no session markers or reaction counts.
2974 """
2975 await _make_repo(db_session)
2976 response = await client.get("/musehub/ui/testuser/test-beats/graph")
2977 assert response.status_code == 200
2978 body = response.text
2979 assert "SESSION_RING_COLOR" in body, "Teal session ring constant missing from graph page"
2980 assert "buildSessionMap" in body, "buildSessionMap function missing from graph page"
2981 assert "fetchReactions" in body, "fetchReactions function missing from graph page"
2982
2983
2984 @pytest.mark.anyio
2985 async def test_session_list_page_returns_200(
2986 client: AsyncClient,
2987 db_session: AsyncSession,
2988 ) -> None:
2989 """GET /musehub/ui/{repo_id}/sessions returns 200 HTML without requiring a JWT."""
2990 repo_id = await _make_repo(db_session)
2991 response = await client.get("/musehub/ui/testuser/test-beats/sessions")
2992 assert response.status_code == 200
2993 assert "text/html" in response.headers["content-type"]
2994 body = response.text
2995 assert "Muse Hub" in body
2996 assert "Sessions" in body
2997 assert "musehub/static/musehub.js" in body
2998
2999
3000 @pytest.mark.anyio
3001 async def test_session_detail_renders(
3002 client: AsyncClient,
3003 db_session: AsyncSession,
3004 ) -> None:
3005 """GET /musehub/ui/{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.anyio
4978 async def test_harmony_json_response(
4979 client: AsyncClient,
4980 db_session: AsyncSession,
4981 auth_headers: dict[str, str],
4982 ) -> None:
4983 """GET /api/v1/musehub/repos/{repo_id}/analysis/{ref}/harmony returns full harmonic JSON."""
4984 repo_id = await _make_repo(db_session)
4985 resp = await client.get(
4986 f"/api/v1/musehub/repos/{repo_id}/analysis/main/harmony",
4987 headers=auth_headers,
4988 )
4989 assert resp.status_code == 200
4990 body = resp.json()
4991 assert body["dimension"] == "harmony"
4992 assert body["ref"] == "main"
4993 data = body["data"]
4994 # Key and mode present
4995 assert "tonic" in data
4996 assert "mode" in data
4997 assert "keyConfidence" in data
4998 # Chord progression
4999 assert "chordProgression" in data
5000 assert isinstance(data["chordProgression"], list)
5001 if data["chordProgression"]:
5002 chord = data["chordProgression"][0]
5003 assert "beat" in chord
5004 assert "chord" in chord
5005 assert "function" in chord
5006 assert "tension" in chord
5007 # Tension curve
5008 assert "tensionCurve" in data
5009 assert isinstance(data["tensionCurve"], list)
5010 # Modulation points
5011 assert "modulationPoints" in data
5012 assert isinstance(data["modulationPoints"], list)
5013 # Total beats
5014 assert "totalBeats" in data
5015 assert data["totalBeats"] > 0
5016
5017 # Listen page tests
5018 # ---------------------------------------------------------------------------
5019
5020
5021 async def _seed_listen_fixtures(db_session: AsyncSession) -> str:
5022 """Seed a repo with audio objects for listen-page tests; return repo_id."""
5023 repo = MusehubRepo(
5024 name="listen-test",
5025 owner="testuser",
5026 slug="listen-test",
5027 visibility="public",
5028 owner_user_id="test-owner",
5029 )
5030 db_session.add(repo)
5031 await db_session.commit()
5032 await db_session.refresh(repo)
5033 repo_id = str(repo.repo_id)
5034
5035 for path, size in [
5036 ("mix/full_mix.mp3", 204800),
5037 ("tracks/bass.mp3", 51200),
5038 ("tracks/keys.mp3", 61440),
5039 ("tracks/bass.webp", 8192),
5040 ]:
5041 obj = MusehubObject(
5042 object_id=f"sha256:{path.replace('/', '_')}",
5043 repo_id=repo_id,
5044 path=path,
5045 size_bytes=size,
5046 disk_path=f"/tmp/{path.replace('/', '_')}",
5047 )
5048 db_session.add(obj)
5049 await db_session.commit()
5050 return repo_id
5051
5052
5053 @pytest.mark.anyio
5054 async def test_listen_page_full_mix(
5055 client: AsyncClient,
5056 db_session: AsyncSession,
5057 ) -> None:
5058 """GET /musehub/ui/{owner}/{repo}/listen/{ref} returns 200 HTML with player UI."""
5059 await _seed_listen_fixtures(db_session)
5060 ref = "main"
5061 response = await client.get(f"/musehub/ui/testuser/listen-test/listen/{ref}")
5062 assert response.status_code == 200
5063 assert "text/html" in response.headers["content-type"]
5064 body = response.text
5065 assert "Muse Hub" in body
5066 assert "listen" in body.lower()
5067 # Full-mix player elements present
5068 assert "mix-play-btn" in body
5069 assert "mix-progress-bar" in body
5070
5071
5072 @pytest.mark.anyio
5073 async def test_listen_page_track_listing(
5074 client: AsyncClient,
5075 db_session: AsyncSession,
5076 ) -> None:
5077 """Listen page HTML embeds track-listing JS that renders per-track controls."""
5078 await _seed_listen_fixtures(db_session)
5079 ref = "main"
5080 response = await client.get(f"/musehub/ui/testuser/listen-test/listen/{ref}")
5081 assert response.status_code == 200
5082 body = response.text
5083 # Track-listing JavaScript is embedded
5084 assert "track-list" in body
5085 assert "track-play-btn" in body or "playTrack" in body
5086
5087
5088 @pytest.mark.anyio
5089 async def test_listen_page_no_renders_fallback(
5090 client: AsyncClient,
5091 db_session: AsyncSession,
5092 ) -> None:
5093 """Listen page renders a friendly fallback when no audio artifacts exist."""
5094 # Repo with no objects at all
5095 repo = MusehubRepo(
5096 name="silent-repo",
5097 owner="testuser",
5098 slug="silent-repo",
5099 visibility="public",
5100 owner_user_id="test-owner",
5101 )
5102 db_session.add(repo)
5103 await db_session.commit()
5104
5105 response = await client.get("/musehub/ui/testuser/silent-repo/listen/main")
5106 assert response.status_code == 200
5107 body = response.text
5108 # Fallback UI marker present (no-renders state)
5109 assert "no-renders" in body or "No audio" in body or "hasRenders" in body
5110
5111
5112 @pytest.mark.anyio
5113 async def test_listen_page_json_response(
5114 client: AsyncClient,
5115 db_session: AsyncSession,
5116 ) -> None:
5117 """GET /musehub/ui/{owner}/{repo}/listen/{ref}?format=json returns TrackListingResponse."""
5118 await _seed_listen_fixtures(db_session)
5119 ref = "main"
5120 response = await client.get(
5121 f"/musehub/ui/testuser/listen-test/listen/{ref}",
5122 params={"format": "json"},
5123 )
5124 assert response.status_code == 200
5125 assert "application/json" in response.headers["content-type"]
5126 body = response.json()
5127 assert "repoId" in body
5128 assert "ref" in body
5129 assert body["ref"] == ref
5130 assert "tracks" in body
5131 assert "hasRenders" in body
5132 assert isinstance(body["tracks"], list)
5133
5134
5135 # ---------------------------------------------------------------------------
5136 # Issue #366 — musehub_listen service function (direct unit tests)
5137 # ---------------------------------------------------------------------------
5138
5139
5140 @pytest.mark.anyio
5141 async def test_build_track_listing_returns_full_mix_and_tracks(
5142 db_session: AsyncSession,
5143 ) -> None:
5144 """build_track_listing() returns a populated TrackListingResponse with mix + stems."""
5145 from musehub.services.musehub_listen import build_track_listing
5146
5147 repo = MusehubRepo(
5148 name="svc-listen-test",
5149 owner="svcuser",
5150 slug="svc-listen-test",
5151 visibility="public",
5152 owner_user_id="svc-owner",
5153 )
5154 db_session.add(repo)
5155 await db_session.commit()
5156 await db_session.refresh(repo)
5157 repo_id = str(repo.repo_id)
5158
5159 for path, size in [
5160 ("mix/full_mix.mp3", 204800),
5161 ("tracks/bass.mp3", 51200),
5162 ("tracks/keys.mp3", 61440),
5163 ("tracks/bass.webp", 8192),
5164 ]:
5165 obj = MusehubObject(
5166 object_id=f"sha256:svc_{path.replace('/', '_')}",
5167 repo_id=repo_id,
5168 path=path,
5169 size_bytes=size,
5170 disk_path=f"/tmp/svc_{path.replace('/', '_')}",
5171 )
5172 db_session.add(obj)
5173 await db_session.commit()
5174
5175 result = await build_track_listing(db_session, repo_id, "main")
5176
5177 assert result.has_renders is True
5178 assert result.repo_id == repo_id
5179 assert result.ref == "main"
5180 # full-mix URL points to the mix file (contains "mix" keyword)
5181 assert result.full_mix_url is not None
5182 assert "full_mix" in result.full_mix_url or "mix" in result.full_mix_url
5183 # Two audio tracks (bass.mp3 + keys.mp3); bass.webp is not audio
5184 assert len(result.tracks) == 3 # mix/full_mix.mp3, tracks/bass.mp3, tracks/keys.mp3
5185 track_paths = {t.path for t in result.tracks}
5186 assert "tracks/bass.mp3" in track_paths
5187 assert "tracks/keys.mp3" in track_paths
5188 # Piano-roll URL attached to bass.mp3 (matching bass.webp exists)
5189 bass_track = next(t for t in result.tracks if t.path == "tracks/bass.mp3")
5190 assert bass_track.piano_roll_url is not None
5191
5192
5193 @pytest.mark.anyio
5194 async def test_build_track_listing_no_audio_returns_empty(
5195 db_session: AsyncSession,
5196 ) -> None:
5197 """build_track_listing() returns has_renders=False when no audio objects exist."""
5198 from musehub.services.musehub_listen import build_track_listing
5199
5200 repo = MusehubRepo(
5201 name="svc-silent-test",
5202 owner="svcuser",
5203 slug="svc-silent-test",
5204 visibility="public",
5205 owner_user_id="svc-owner",
5206 )
5207 db_session.add(repo)
5208 await db_session.commit()
5209 await db_session.refresh(repo)
5210 repo_id = str(repo.repo_id)
5211
5212 # Only a non-audio object
5213 obj = MusehubObject(
5214 object_id="sha256:svc_midi",
5215 repo_id=repo_id,
5216 path="tracks/bass.mid",
5217 size_bytes=1024,
5218 disk_path="/tmp/svc_bass.mid",
5219 )
5220 db_session.add(obj)
5221 await db_session.commit()
5222
5223 result = await build_track_listing(db_session, repo_id, "dev")
5224
5225 assert result.has_renders is False
5226 assert result.full_mix_url is None
5227 assert result.tracks == []
5228
5229
5230 @pytest.mark.anyio
5231 async def test_build_track_listing_no_mix_keyword_uses_first_alphabetically(
5232 db_session: AsyncSession,
5233 ) -> None:
5234 """When no file matches _FULL_MIX_KEYWORDS, the first audio file (by path) is used."""
5235 from musehub.services.musehub_listen import build_track_listing
5236
5237 repo = MusehubRepo(
5238 name="svc-nomix-test",
5239 owner="svcuser",
5240 slug="svc-nomix-test",
5241 visibility="public",
5242 owner_user_id="svc-owner",
5243 )
5244 db_session.add(repo)
5245 await db_session.commit()
5246 await db_session.refresh(repo)
5247 repo_id = str(repo.repo_id)
5248
5249 for path, size in [
5250 ("tracks/bass.mp3", 51200),
5251 ("tracks/drums.mp3", 61440),
5252 ]:
5253 obj = MusehubObject(
5254 object_id=f"sha256:svc_nomix_{path.replace('/', '_')}",
5255 repo_id=repo_id,
5256 path=path,
5257 size_bytes=size,
5258 disk_path=f"/tmp/svc_nomix_{path.replace('/', '_')}",
5259 )
5260 db_session.add(obj)
5261 await db_session.commit()
5262
5263 result = await build_track_listing(db_session, repo_id, "main")
5264
5265 assert result.has_renders is True
5266 # 'tracks/bass.mp3' sorts before 'tracks/drums.mp3'
5267 assert result.full_mix_url is not None
5268 assert "bass" in result.full_mix_url
5269
5270
5271 # ---------------------------------------------------------------------------
5272 # Issue #206 — Commit list page
5273 # ---------------------------------------------------------------------------
5274
5275 _COMMIT_LIST_OWNER = "commitowner"
5276 _COMMIT_LIST_SLUG = "commit-list-repo"
5277 _SHA_MAIN_1 = "aa001122334455667788990011223344556677889900"
5278 _SHA_MAIN_2 = "bb001122334455667788990011223344556677889900"
5279 _SHA_MAIN_MERGE = "cc001122334455667788990011223344556677889900"
5280 _SHA_FEAT = "ff001122334455667788990011223344556677889900"
5281
5282
5283 async def _seed_commit_list_repo(
5284 db_session: AsyncSession,
5285 ) -> str:
5286 """Seed a repo with 2 commits on main, 1 merge commit, and 1 on feat branch."""
5287 repo = MusehubRepo(
5288 name=_COMMIT_LIST_SLUG,
5289 owner=_COMMIT_LIST_OWNER,
5290 slug=_COMMIT_LIST_SLUG,
5291 visibility="public",
5292 owner_user_id="commit-owner-uid",
5293 )
5294 db_session.add(repo)
5295 await db_session.flush()
5296 repo_id = str(repo.repo_id)
5297
5298 branch_main = MusehubBranch(repo_id=repo_id, name="main", head_commit_id=_SHA_MAIN_MERGE)
5299 branch_feat = MusehubBranch(repo_id=repo_id, name="feat/drums", head_commit_id=_SHA_FEAT)
5300 db_session.add_all([branch_main, branch_feat])
5301
5302 now = datetime.now(UTC)
5303 commits = [
5304 MusehubCommit(
5305 commit_id=_SHA_MAIN_1,
5306 repo_id=repo_id,
5307 branch="main",
5308 parent_ids=[],
5309 message="feat(bass): root commit with walking bass line",
5310 author="composer@muse.app",
5311 timestamp=now - timedelta(hours=4),
5312 ),
5313 MusehubCommit(
5314 commit_id=_SHA_MAIN_2,
5315 repo_id=repo_id,
5316 branch="main",
5317 parent_ids=[_SHA_MAIN_1],
5318 message="feat(keys): add rhodes chord voicings in verse",
5319 author="composer@muse.app",
5320 timestamp=now - timedelta(hours=2),
5321 ),
5322 MusehubCommit(
5323 commit_id=_SHA_MAIN_MERGE,
5324 repo_id=repo_id,
5325 branch="main",
5326 parent_ids=[_SHA_MAIN_2, _SHA_FEAT],
5327 message="merge(feat/drums): integrate drum pattern into main",
5328 author="composer@muse.app",
5329 timestamp=now - timedelta(hours=1),
5330 ),
5331 MusehubCommit(
5332 commit_id=_SHA_FEAT,
5333 repo_id=repo_id,
5334 branch="feat/drums",
5335 parent_ids=[_SHA_MAIN_1],
5336 message="feat(drums): add kick and snare pattern at 120 BPM",
5337 author="drummer@muse.app",
5338 timestamp=now - timedelta(hours=3),
5339 ),
5340 ]
5341 db_session.add_all(commits)
5342 await db_session.commit()
5343 return repo_id
5344
5345
5346 @pytest.mark.anyio
5347 async def test_commits_list_page_returns_200(
5348 client: AsyncClient,
5349 db_session: AsyncSession,
5350 ) -> None:
5351 """GET /{owner}/{repo}/commits returns 200 HTML."""
5352 await _seed_commit_list_repo(db_session)
5353 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5354 assert resp.status_code == 200
5355 assert "text/html" in resp.headers["content-type"]
5356 assert "Muse Hub" in resp.text
5357
5358
5359 @pytest.mark.anyio
5360 async def test_commits_list_page_shows_commit_sha(
5361 client: AsyncClient,
5362 db_session: AsyncSession,
5363 ) -> None:
5364 """Commit SHA (first 8 chars) appears in the rendered HTML."""
5365 await _seed_commit_list_repo(db_session)
5366 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5367 assert resp.status_code == 200
5368 # All 4 commits should appear (per_page=30 default, total=4)
5369 assert _SHA_MAIN_1[:8] in resp.text
5370 assert _SHA_MAIN_2[:8] in resp.text
5371 assert _SHA_MAIN_MERGE[:8] in resp.text
5372 assert _SHA_FEAT[:8] in resp.text
5373
5374
5375 @pytest.mark.anyio
5376 async def test_commits_list_page_shows_commit_message(
5377 client: AsyncClient,
5378 db_session: AsyncSession,
5379 ) -> None:
5380 """Commit messages appear truncated in commit rows."""
5381 await _seed_commit_list_repo(db_session)
5382 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5383 assert resp.status_code == 200
5384 assert "walking bass line" in resp.text
5385 assert "rhodes chord voicings" in resp.text
5386
5387
5388 @pytest.mark.anyio
5389 async def test_commits_list_page_dag_indicator(
5390 client: AsyncClient,
5391 db_session: AsyncSession,
5392 ) -> None:
5393 """DAG node CSS class is present in the HTML for every commit row."""
5394 await _seed_commit_list_repo(db_session)
5395 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5396 assert resp.status_code == 200
5397 assert "dag-node" in resp.text
5398 assert "commit-list-row" in resp.text
5399
5400
5401 @pytest.mark.anyio
5402 async def test_commits_list_page_merge_indicator(
5403 client: AsyncClient,
5404 db_session: AsyncSession,
5405 ) -> None:
5406 """Merge commits display the merge indicator and dag-node-merge class."""
5407 await _seed_commit_list_repo(db_session)
5408 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5409 assert resp.status_code == 200
5410 assert "dag-node-merge" in resp.text
5411 assert "merge" in resp.text.lower()
5412
5413
5414 @pytest.mark.anyio
5415 async def test_commits_list_page_branch_selector(
5416 client: AsyncClient,
5417 db_session: AsyncSession,
5418 ) -> None:
5419 """Branch <select> dropdown is present when the repo has branches."""
5420 await _seed_commit_list_repo(db_session)
5421 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5422 assert resp.status_code == 200
5423 # Select element with branch options
5424 assert "branch-sel" in resp.text
5425 assert "main" in resp.text
5426 assert "feat/drums" in resp.text
5427
5428
5429 @pytest.mark.anyio
5430 async def test_commits_list_page_graph_link(
5431 client: AsyncClient,
5432 db_session: AsyncSession,
5433 ) -> None:
5434 """Link to the DAG graph page is present."""
5435 await _seed_commit_list_repo(db_session)
5436 resp = await client.get(f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5437 assert resp.status_code == 200
5438 assert "/graph" in resp.text
5439
5440
5441 @pytest.mark.anyio
5442 async def test_commits_list_page_pagination_links(
5443 client: AsyncClient,
5444 db_session: AsyncSession,
5445 ) -> None:
5446 """Pagination nav links appear when total exceeds per_page."""
5447 await _seed_commit_list_repo(db_session)
5448 # Request per_page=2 so 4 commits produce 2 pages
5449 resp = await client.get(
5450 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=1"
5451 )
5452 assert resp.status_code == 200
5453 body = resp.text
5454 # "Older" link should be active (page 1 has no "Newer")
5455 assert "Older" in body
5456 # "Newer" should be disabled on page 1
5457 assert "Newer" in body
5458 assert "page=2" in body
5459
5460
5461 @pytest.mark.anyio
5462 async def test_commits_list_page_pagination_page2(
5463 client: AsyncClient,
5464 db_session: AsyncSession,
5465 ) -> None:
5466 """Page 2 renders with Newer navigation active."""
5467 await _seed_commit_list_repo(db_session)
5468 resp = await client.get(
5469 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=2"
5470 )
5471 assert resp.status_code == 200
5472 body = resp.text
5473 assert "page=1" in body # "Newer" link points back to page 1
5474
5475
5476 @pytest.mark.anyio
5477 async def test_commits_list_page_branch_filter_html(
5478 client: AsyncClient,
5479 db_session: AsyncSession,
5480 ) -> None:
5481 """?branch=main returns only main-branch commits in HTML."""
5482 await _seed_commit_list_repo(db_session)
5483 resp = await client.get(
5484 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?branch=main"
5485 )
5486 assert resp.status_code == 200
5487 body = resp.text
5488 # main commits appear
5489 assert _SHA_MAIN_1[:8] in body
5490 assert _SHA_MAIN_2[:8] in body
5491 assert _SHA_MAIN_MERGE[:8] in body
5492 # feat/drums commit should NOT appear when filtered to main
5493 assert _SHA_FEAT[:8] not in body
5494
5495
5496 @pytest.mark.anyio
5497 async def test_commits_list_page_json_content_negotiation(
5498 client: AsyncClient,
5499 db_session: AsyncSession,
5500 ) -> None:
5501 """?format=json returns CommitListResponse JSON with commits and total."""
5502 await _seed_commit_list_repo(db_session)
5503 resp = await client.get(
5504 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?format=json"
5505 )
5506 assert resp.status_code == 200
5507 assert "application/json" in resp.headers["content-type"]
5508 body = resp.json()
5509 assert "commits" in body
5510 assert "total" in body
5511 assert body["total"] == 4
5512 assert len(body["commits"]) == 4
5513 # Commits are newest first; merge commit has timestamp now-1h (most recent)
5514 commit_ids = [c["commitId"] for c in body["commits"]]
5515 assert commit_ids[0] == _SHA_MAIN_MERGE
5516
5517
5518 @pytest.mark.anyio
5519 async def test_commits_list_page_json_pagination(
5520 client: AsyncClient,
5521 db_session: AsyncSession,
5522 ) -> None:
5523 """JSON with per_page=1&page=2 returns the second commit."""
5524 await _seed_commit_list_repo(db_session)
5525 resp = await client.get(
5526 f"/musehub/ui/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits"
5527 "?format=json&per_page=1&page=2"
5528 )
5529 assert resp.status_code == 200
5530 body = resp.json()
5531 assert body["total"] == 4
5532 assert len(body["commits"]) == 1
5533 # Page 2 (newest-first) is the second most-recent commit.
5534 # Newest: _SHA_MAIN_MERGE (now-1h), then _SHA_MAIN_2 (now-2h)
5535 assert body["commits"][0]["commitId"] == _SHA_MAIN_2
5536
5537
5538 @pytest.mark.anyio
5539 async def test_commits_list_page_empty_state(
5540 client: AsyncClient,
5541 db_session: AsyncSession,
5542 ) -> None:
5543 """A repo with no commits shows the empty state message."""
5544 repo = MusehubRepo(
5545 name="empty-repo",
5546 owner="emptyowner",
5547 slug="empty-repo",
5548 visibility="public",
5549 owner_user_id="empty-owner-uid",
5550 )
5551 db_session.add(repo)
5552 await db_session.commit()
5553
5554 resp = await client.get("/musehub/ui/emptyowner/empty-repo/commits")
5555 assert resp.status_code == 200
5556 assert "No commits yet" in resp.text or "muse push" in resp.text
5557
5558
5559 # ---------------------------------------------------------------------------
5560
5561
5562
5563 # ---------------------------------------------------------------------------
5564 # Commit detail enhancements — # ---------------------------------------------------------------------------
5565
5566
5567 async def _seed_commit_detail_fixtures(
5568 db_session: AsyncSession,
5569 ) -> tuple[str, str, str]:
5570 """Seed a public repo with a parent commit and a child commit.
5571
5572 Returns (repo_id, parent_commit_id, child_commit_id).
5573 """
5574 repo = MusehubRepo(
5575 name="commit-detail-test",
5576 owner="testuser",
5577 slug="commit-detail-test",
5578 visibility="public",
5579 owner_user_id="test-owner",
5580 )
5581 db_session.add(repo)
5582 await db_session.flush()
5583 repo_id = str(repo.repo_id)
5584
5585 branch = MusehubBranch(
5586 repo_id=repo_id,
5587 name="main",
5588 head_commit_id=None,
5589 )
5590 db_session.add(branch)
5591
5592 parent_commit_id = "aaaa0000111122223333444455556666aaaabbbb"
5593 child_commit_id = "bbbb1111222233334444555566667777bbbbcccc"
5594
5595 parent_commit = MusehubCommit(
5596 repo_id=repo_id,
5597 commit_id=parent_commit_id,
5598 branch="main",
5599 parent_ids=[],
5600 message="init: establish harmonic foundation in C major\n\nKey: C major\nBPM: 120\nMeter: 4/4",
5601 author="testuser",
5602 timestamp=datetime.now(UTC) - timedelta(hours=2),
5603 snapshot_id=None,
5604 )
5605 child_commit = MusehubCommit(
5606 repo_id=repo_id,
5607 commit_id=child_commit_id,
5608 branch="main",
5609 parent_ids=[parent_commit_id],
5610 message="feat(keys): add melodic piano phrase in D minor\n\nKey: D minor\nBPM: 132\nMeter: 3/4\nSection: verse",
5611 author="testuser",
5612 timestamp=datetime.now(UTC) - timedelta(hours=1),
5613 snapshot_id=None,
5614 )
5615 db_session.add(parent_commit)
5616 db_session.add(child_commit)
5617 await db_session.commit()
5618 return repo_id, parent_commit_id, child_commit_id
5619
5620
5621 @pytest.mark.anyio
5622 async def test_commit_detail_page_renders_enhanced_metadata(
5623 client: AsyncClient,
5624 db_session: AsyncSession,
5625 ) -> None:
5626 """Commit detail page SSR renders commit header fields (SHA, author, branch, parent link)."""
5627 await _seed_commit_detail_fixtures(db_session)
5628 sha = "bbbb1111222233334444555566667777bbbbcccc"
5629 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
5630 assert response.status_code == 200
5631 assert "text/html" in response.headers["content-type"]
5632 body = response.text
5633 # SSR commit header — short SHA present
5634 assert "bbbb1111" in body
5635 # Author field rendered server-side
5636 assert "testuser" in body
5637 # Parent SHA navigation link present
5638 assert "aaaa0000" in body
5639
5640
5641 @pytest.mark.anyio
5642 async def test_commit_detail_audio_shell_with_snapshot_id(
5643 client: AsyncClient,
5644 db_session: AsyncSession,
5645 ) -> None:
5646 """Commit with snapshot_id gets a WaveSurfer shell rendered by the server."""
5647 from datetime import datetime, timezone
5648
5649 _repo_id, _parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5650 repo = MusehubRepo(
5651 name="audio-test-repo",
5652 owner="testuser",
5653 slug="audio-test-repo",
5654 visibility="public",
5655 owner_user_id="test-owner",
5656 )
5657 db_session.add(repo)
5658 await db_session.flush()
5659 snap_id = "sha256:deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678"
5660 commit_with_audio = MusehubCommit(
5661 commit_id="cccc2222333344445555666677778888ccccdddd",
5662 repo_id=str(repo.repo_id),
5663 branch="main",
5664 parent_ids=[],
5665 message="Commit with audio snapshot",
5666 author="testuser",
5667 timestamp=datetime.now(tz=timezone.utc),
5668 snapshot_id=snap_id,
5669 )
5670 db_session.add(commit_with_audio)
5671 await db_session.commit()
5672
5673 response = await client.get(
5674 f"/musehub/ui/testuser/audio-test-repo/commits/cccc2222333344445555666677778888ccccdddd"
5675 )
5676 assert response.status_code == 200
5677 body = response.text
5678 assert "commit-waveform" in body
5679 assert snap_id in body
5680
5681
5682 @pytest.mark.anyio
5683 async def test_commit_detail_ssr_message_present_in_body(
5684 client: AsyncClient,
5685 db_session: AsyncSession,
5686 ) -> None:
5687 """Commit message text is rendered in the SSR page body (replaces JS renderCommitBody)."""
5688 await _seed_commit_detail_fixtures(db_session)
5689 sha = "bbbb1111222233334444555566667777bbbbcccc"
5690 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
5691 assert response.status_code == 200
5692 body = response.text
5693 # SSR renders the commit message directly — no JS renderCommitBody needed
5694 assert "feat(keys): add melodic piano phrase in D minor" in body
5695
5696
5697 @pytest.mark.anyio
5698 async def test_commit_detail_diff_summary_endpoint_returns_five_dimensions(
5699 client: AsyncClient,
5700 db_session: AsyncSession,
5701 auth_headers: dict[str, str],
5702 ) -> None:
5703 """GET /api/v1/musehub/repos/{repo_id}/commits/{sha}/diff-summary returns 5 dimensions."""
5704 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5705 response = await client.get(
5706 f"/api/v1/musehub/repos/{repo_id}/commits/{child_id}/diff-summary",
5707 headers=auth_headers,
5708 )
5709 assert response.status_code == 200
5710 data = response.json()
5711 assert data["commitId"] == child_id
5712 assert data["parentId"] == _parent_id
5713 assert "dimensions" in data
5714 assert len(data["dimensions"]) == 5
5715 dim_names = {d["dimension"] for d in data["dimensions"]}
5716 assert dim_names == {"harmonic", "rhythmic", "melodic", "structural", "dynamic"}
5717 for dim in data["dimensions"]:
5718 assert 0.0 <= dim["score"] <= 1.0
5719 assert dim["label"] in {"none", "low", "medium", "high"}
5720 assert dim["color"] in {"dim-none", "dim-low", "dim-medium", "dim-high"}
5721 assert "overallScore" in data
5722 assert 0.0 <= data["overallScore"] <= 1.0
5723
5724
5725 @pytest.mark.anyio
5726 async def test_commit_detail_diff_summary_root_commit_scores_one(
5727 client: AsyncClient,
5728 db_session: AsyncSession,
5729 auth_headers: dict[str, str],
5730 ) -> None:
5731 """Diff summary for a root commit (no parent) scores all dimensions at 1.0."""
5732 repo_id, parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5733 response = await client.get(
5734 f"/api/v1/musehub/repos/{repo_id}/commits/{parent_id}/diff-summary",
5735 headers=auth_headers,
5736 )
5737 assert response.status_code == 200
5738 data = response.json()
5739 assert data["parentId"] is None
5740 for dim in data["dimensions"]:
5741 assert dim["score"] == 1.0
5742 assert dim["label"] == "high"
5743
5744
5745 @pytest.mark.anyio
5746 async def test_commit_detail_diff_summary_keyword_detection(
5747 client: AsyncClient,
5748 db_session: AsyncSession,
5749 auth_headers: dict[str, str],
5750 ) -> None:
5751 """Diff summary detects melodic keyword in child commit message."""
5752 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5753 response = await client.get(
5754 f"/api/v1/musehub/repos/{repo_id}/commits/{child_id}/diff-summary",
5755 headers=auth_headers,
5756 )
5757 assert response.status_code == 200
5758 data = response.json()
5759 melodic_dim = next(d for d in data["dimensions"] if d["dimension"] == "melodic")
5760 # child commit message contains "melodic" keyword → non-zero score
5761 assert melodic_dim["score"] > 0.0
5762
5763
5764 @pytest.mark.anyio
5765 async def test_commit_detail_diff_summary_unknown_commit_404(
5766 client: AsyncClient,
5767 db_session: AsyncSession,
5768 auth_headers: dict[str, str],
5769 ) -> None:
5770 """Diff summary for unknown commit ID returns 404."""
5771 repo_id, _p, _c = await _seed_commit_detail_fixtures(db_session)
5772 response = await client.get(
5773 f"/api/v1/musehub/repos/{repo_id}/commits/deadbeefdeadbeefdeadbeef/diff-summary",
5774 headers=auth_headers, )
5775 assert response.status_code == 404
5776
5777
5778 # ---------------------------------------------------------------------------
5779 # Commit comment threads — # ---------------------------------------------------------------------------
5780
5781
5782 @pytest.mark.anyio
5783 async def test_commit_page_has_comment_section_html(
5784 client: AsyncClient,
5785 db_session: AsyncSession,
5786 ) -> None:
5787 """Commit detail page HTML includes the HTMX comment target container."""
5788 from datetime import datetime, timezone
5789
5790 repo_id = await _make_repo(db_session)
5791 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5792 commit = MusehubCommit(
5793 commit_id=commit_id,
5794 repo_id=repo_id,
5795 branch="main",
5796 parent_ids=[],
5797 message="Add chorus section",
5798 author="testuser",
5799 timestamp=datetime.now(tz=timezone.utc),
5800 )
5801 db_session.add(commit)
5802 await db_session.commit()
5803
5804 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5805 assert response.status_code == 200
5806 body = response.text
5807 # SSR replaces JS-loaded comment section with a server-rendered HTMX target
5808 assert "commit-comments" in body
5809 assert "hx-target" in body
5810
5811
5812 @pytest.mark.anyio
5813 async def test_commit_page_has_htmx_comment_form(
5814 client: AsyncClient,
5815 db_session: AsyncSession,
5816 ) -> None:
5817 """Commit detail page has an HTMX-driven comment form (replaces old JS comment functions)."""
5818 from datetime import datetime, timezone
5819
5820 repo_id = await _make_repo(db_session)
5821 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5822 commit = MusehubCommit(
5823 commit_id=commit_id,
5824 repo_id=repo_id,
5825 branch="main",
5826 parent_ids=[],
5827 message="Add chorus section",
5828 author="testuser",
5829 timestamp=datetime.now(tz=timezone.utc),
5830 )
5831 db_session.add(commit)
5832 await db_session.commit()
5833
5834 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5835 assert response.status_code == 200
5836 body = response.text
5837 # HTMX form replaces JS renderComments/submitComment/loadComments
5838 assert "hx-post" in body
5839 assert "hx-target" in body
5840 assert "textarea" in body
5841
5842
5843 @pytest.mark.anyio
5844 async def test_commit_page_comment_htmx_target_present(
5845 client: AsyncClient,
5846 db_session: AsyncSession,
5847 ) -> None:
5848 """HTMX comment target div is present for server-side comment injection."""
5849 from datetime import datetime, timezone
5850
5851 repo_id = await _make_repo(db_session)
5852 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5853 commit = MusehubCommit(
5854 commit_id=commit_id,
5855 repo_id=repo_id,
5856 branch="main",
5857 parent_ids=[],
5858 message="Add chorus section",
5859 author="testuser",
5860 timestamp=datetime.now(tz=timezone.utc),
5861 )
5862 db_session.add(commit)
5863 await db_session.commit()
5864
5865 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5866 assert response.status_code == 200
5867 body = response.text
5868 assert 'id="commit-comments"' in body
5869
5870
5871 @pytest.mark.anyio
5872 async def test_commit_page_comment_htmx_posts_to_comments_endpoint(
5873 client: AsyncClient,
5874 db_session: AsyncSession,
5875 ) -> None:
5876 """HTMX form posts to the commit comments endpoint (replaces old JS API fetch)."""
5877 from datetime import datetime, timezone
5878
5879 repo_id = await _make_repo(db_session)
5880 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5881 commit = MusehubCommit(
5882 commit_id=commit_id,
5883 repo_id=repo_id,
5884 branch="main",
5885 parent_ids=[],
5886 message="Add chorus section",
5887 author="testuser",
5888 timestamp=datetime.now(tz=timezone.utc),
5889 )
5890 db_session.add(commit)
5891 await db_session.commit()
5892
5893 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5894 assert response.status_code == 200
5895 body = response.text
5896 assert "hx-post" in body
5897 assert "/comments" in body
5898
5899
5900 @pytest.mark.anyio
5901 async def test_commit_page_comment_has_ssr_avatar(
5902 client: AsyncClient,
5903 db_session: AsyncSession,
5904 ) -> None:
5905 """Commit page SSR comment thread renders avatar initials via server-side template."""
5906 from datetime import datetime, timezone
5907
5908 repo_id = await _make_repo(db_session)
5909 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5910 commit = MusehubCommit(
5911 commit_id=commit_id,
5912 repo_id=repo_id,
5913 branch="main",
5914 parent_ids=[],
5915 message="Add chorus section",
5916 author="testuser",
5917 timestamp=datetime.now(tz=timezone.utc),
5918 )
5919 db_session.add(commit)
5920 await db_session.commit()
5921
5922 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5923 assert response.status_code == 200
5924 body = response.text
5925 # SSR avatar class from commit_comments.html fragment
5926 assert "comment-avatar" in body
5927
5928
5929 @pytest.mark.anyio
5930 async def test_commit_page_comment_has_htmx_form_elements(
5931 client: AsyncClient,
5932 db_session: AsyncSession,
5933 ) -> None:
5934 """Commit page HTMX comment form has textarea and submit button."""
5935 from datetime import datetime, timezone
5936
5937 repo_id = await _make_repo(db_session)
5938 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5939 commit = MusehubCommit(
5940 commit_id=commit_id,
5941 repo_id=repo_id,
5942 branch="main",
5943 parent_ids=[],
5944 message="Add chorus section",
5945 author="testuser",
5946 timestamp=datetime.now(tz=timezone.utc),
5947 )
5948 db_session.add(commit)
5949 await db_session.commit()
5950
5951 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5952 assert response.status_code == 200
5953 body = response.text
5954 # HTMX form replaces old new-comment-form/new-comment-body/comment-submit-btn
5955 assert 'name="body"' in body
5956 assert "btn-primary" in body
5957 assert "Comment" in body
5958
5959
5960 @pytest.mark.anyio
5961 async def test_commit_page_comment_section_shows_count_heading(
5962 client: AsyncClient,
5963 db_session: AsyncSession,
5964 ) -> None:
5965 """Commit page SSR comment section shows a count heading (replaces 'Discussion' heading)."""
5966 from datetime import datetime, timezone
5967
5968 repo_id = await _make_repo(db_session)
5969 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5970 commit = MusehubCommit(
5971 commit_id=commit_id,
5972 repo_id=repo_id,
5973 branch="main",
5974 parent_ids=[],
5975 message="Add chorus section",
5976 author="testuser",
5977 timestamp=datetime.now(tz=timezone.utc),
5978 )
5979 db_session.add(commit)
5980 await db_session.commit()
5981
5982 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
5983 assert response.status_code == 200
5984 body = response.text
5985 assert "comment" in body
5986
5987
5988 # ---------------------------------------------------------------------------
5989 # Commit detail enhancements — ref URL links, DB tags in panel, prose
5990 # summary
5991 # ---------------------------------------------------------------------------
5992
5993
5994 @pytest.mark.anyio
5995 async def test_commit_page_ssr_renders_commit_message(
5996 client: AsyncClient,
5997 db_session: AsyncSession,
5998 ) -> None:
5999 """Commit message is rendered server-side (replaces JS ref-tag / tagPill rendering)."""
6000 from datetime import datetime, timezone
6001
6002 repo_id = await _make_repo(db_session)
6003 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6004 commit = MusehubCommit(
6005 commit_id=commit_id,
6006 repo_id=repo_id,
6007 branch="main",
6008 parent_ids=[],
6009 message="Unique groove message XYZ",
6010 author="testuser",
6011 timestamp=datetime.now(tz=timezone.utc),
6012 )
6013 db_session.add(commit)
6014 await db_session.commit()
6015
6016 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
6017 assert response.status_code == 200
6018 body = response.text
6019 # SSR renders commit message directly — no JS tagPill/isRefUrl needed
6020 assert "Unique groove message XYZ" in body
6021
6022
6023 @pytest.mark.anyio
6024 async def test_commit_page_ssr_renders_author_metadata(
6025 client: AsyncClient,
6026 db_session: AsyncSession,
6027 ) -> None:
6028 """Commit author and branch appear in the SSR metadata grid (replaces JS muse-tags panel)."""
6029 from datetime import datetime, timezone
6030
6031 repo_id = await _make_repo(db_session)
6032 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6033 commit = MusehubCommit(
6034 commit_id=commit_id,
6035 repo_id=repo_id,
6036 branch="main",
6037 parent_ids=[],
6038 message="Add chorus section",
6039 author="jazzproducer",
6040 timestamp=datetime.now(tz=timezone.utc),
6041 )
6042 db_session.add(commit)
6043 await db_session.commit()
6044
6045 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
6046 assert response.status_code == 200
6047 body = response.text
6048 # SSR metadata grid shows author — no JS loadMuseTagsPanel needed
6049 assert "jazzproducer" in body
6050
6051
6052 @pytest.mark.anyio
6053 async def test_commit_page_no_audio_shell_when_no_snapshot(
6054 client: AsyncClient,
6055 db_session: AsyncSession,
6056 ) -> None:
6057 """Commit page without snapshot_id omits WaveSurfer shell (replaces buildProseSummary check)."""
6058 from datetime import datetime, timezone
6059
6060 repo_id = await _make_repo(db_session)
6061 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6062 commit = MusehubCommit(
6063 commit_id=commit_id,
6064 repo_id=repo_id,
6065 branch="main",
6066 parent_ids=[],
6067 message="Add chorus section",
6068 author="testuser",
6069 timestamp=datetime.now(tz=timezone.utc),
6070 snapshot_id=None,
6071 )
6072 db_session.add(commit)
6073 await db_session.commit()
6074
6075 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
6076 assert response.status_code == 200
6077 body = response.text
6078 assert "commit-waveform" not in body
6079
6080
6081 # ---------------------------------------------------------------------------
6082 # Audio player — listen page tests
6083 # ---------------------------------------------------------------------------
6084
6085
6086 @pytest.mark.anyio
6087 async def test_listen_page_renders(
6088 client: AsyncClient,
6089 db_session: AsyncSession,
6090 ) -> None:
6091 """GET /musehub/ui/{owner}/{slug}/listen/{ref} must return 200 HTML."""
6092 await _make_repo(db_session)
6093 ref = "abc1234567890abcdef"
6094 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6095 assert response.status_code == 200
6096 assert "text/html" in response.headers["content-type"]
6097
6098
6099 @pytest.mark.anyio
6100 async def test_listen_page_no_auth_required(
6101 client: AsyncClient,
6102 db_session: AsyncSession,
6103 ) -> None:
6104 """Listen page must be accessible without an Authorization header."""
6105 await _make_repo(db_session)
6106 ref = "deadbeef1234"
6107 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6108 assert response.status_code != 401
6109 assert response.status_code == 200
6110
6111
6112 @pytest.mark.anyio
6113 async def test_listen_page_contains_waveform_ui(
6114 client: AsyncClient,
6115 db_session: AsyncSession,
6116 ) -> None:
6117 """Listen page HTML must contain the waveform container element."""
6118 await _make_repo(db_session)
6119 ref = "cafebabe1234"
6120 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6121 assert response.status_code == 200
6122 body = response.text
6123 assert "waveform" in body
6124
6125
6126 @pytest.mark.anyio
6127 async def test_listen_page_contains_play_button(
6128 client: AsyncClient,
6129 db_session: AsyncSession,
6130 ) -> None:
6131 """Listen page must include a play button element."""
6132 await _make_repo(db_session)
6133 ref = "feed1234abcdef"
6134 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6135 assert response.status_code == 200
6136 body = response.text
6137 assert "play-btn" in body
6138
6139
6140 @pytest.mark.anyio
6141 async def test_listen_page_contains_speed_selector(
6142 client: AsyncClient,
6143 db_session: AsyncSession,
6144 ) -> None:
6145 """Listen page must include the playback speed selector element."""
6146 await _make_repo(db_session)
6147 ref = "1a2b3c4d5e6f7890"
6148 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6149 assert response.status_code == 200
6150 body = response.text
6151 assert "speed-sel" in body
6152
6153
6154 @pytest.mark.anyio
6155 async def test_listen_page_contains_ab_loop_ui(
6156 client: AsyncClient,
6157 db_session: AsyncSession,
6158 ) -> None:
6159 """Listen page must include A/B loop controls (loop info + clear button)."""
6160 await _make_repo(db_session)
6161 ref = "aabbccddeeff0011"
6162 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6163 assert response.status_code == 200
6164 body = response.text
6165 assert "loop-info" in body
6166 assert "loop-clear-btn" in body
6167
6168
6169 @pytest.mark.anyio
6170 async def test_listen_page_loads_wavesurfer_vendor(
6171 client: AsyncClient,
6172 db_session: AsyncSession,
6173 ) -> None:
6174 """Listen page must load the vendored wavesurfer.min.js — no external CDN."""
6175 await _make_repo(db_session)
6176 ref = "112233445566778899"
6177 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6178 assert response.status_code == 200
6179 body = response.text
6180 # Must reference the local vendor path — never an external CDN URL
6181 assert "vendor/wavesurfer.min.js" in body
6182 assert "unpkg.com" not in body
6183 assert "cdn.jsdelivr.net" not in body
6184 assert "cdnjs.cloudflare.com" not in body
6185
6186
6187 @pytest.mark.anyio
6188 async def test_listen_page_loads_audio_player_js(
6189 client: AsyncClient,
6190 db_session: AsyncSession,
6191 ) -> None:
6192 """Listen page must load the audio-player.js component wrapper script."""
6193 await _make_repo(db_session)
6194 ref = "99aabbccddeeff00"
6195 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6196 assert response.status_code == 200
6197 body = response.text
6198 assert "audio-player.js" in body
6199
6200
6201 @pytest.mark.anyio
6202 async def test_listen_track_page_renders(
6203 client: AsyncClient,
6204 db_session: AsyncSession,
6205 ) -> None:
6206 """GET /musehub/ui/{owner}/{slug}/listen/{ref}/{path} must return 200."""
6207 await _make_repo(db_session)
6208 ref = "feedface0011aabb"
6209 response = await client.get(
6210 f"/musehub/ui/testuser/test-beats/listen/{ref}/tracks/bass.mp3"
6211 )
6212 assert response.status_code == 200
6213 assert "text/html" in response.headers["content-type"]
6214
6215
6216 @pytest.mark.anyio
6217 async def test_listen_track_page_has_track_path_in_js(
6218 client: AsyncClient,
6219 db_session: AsyncSession,
6220 ) -> None:
6221 """Track path must be injected into the page JS context as TRACK_PATH."""
6222 await _make_repo(db_session)
6223 ref = "00aabbccddeeff11"
6224 track = "tracks/lead-guitar.mp3"
6225 response = await client.get(
6226 f"/musehub/ui/testuser/test-beats/listen/{ref}/{track}"
6227 )
6228 assert response.status_code == 200
6229 body = response.text
6230 assert "TRACK_PATH" in body
6231 assert "lead-guitar.mp3" in body
6232
6233
6234 @pytest.mark.anyio
6235 async def test_listen_page_unknown_repo_404(
6236 client: AsyncClient,
6237 db_session: AsyncSession,
6238 ) -> None:
6239 """GET listen page with nonexistent owner/slug must return 404."""
6240 response = await client.get(
6241 "/musehub/ui/nobody/nonexistent-repo/listen/abc123"
6242 )
6243 assert response.status_code == 404
6244
6245
6246 @pytest.mark.anyio
6247 async def test_listen_page_keyboard_shortcuts_documented(
6248 client: AsyncClient,
6249 db_session: AsyncSession,
6250 ) -> None:
6251 """Listen page must document Space, arrow, and L keyboard shortcuts."""
6252 await _make_repo(db_session)
6253 ref = "cafe0011aabb2233"
6254 response = await client.get(f"/musehub/ui/testuser/test-beats/listen/{ref}")
6255 assert response.status_code == 200
6256 body = response.text
6257 # Keyboard hint section must be present
6258 assert "Space" in body or "space" in body.lower()
6259 assert "loop" in body.lower()
6260
6261
6262 # ---------------------------------------------------------------------------
6263 # Compare view
6264 # ---------------------------------------------------------------------------
6265
6266
6267 @pytest.mark.anyio
6268 async def test_compare_page_renders(
6269 client: AsyncClient,
6270 db_session: AsyncSession,
6271 ) -> None:
6272 """GET /musehub/ui/{owner}/{slug}/compare/{base}...{head} returns 200 HTML."""
6273 await _make_repo(db_session)
6274 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6275 assert response.status_code == 200
6276 assert "text/html" in response.headers["content-type"]
6277 body = response.text
6278 assert "Muse Hub" in body
6279 assert "main" in body
6280 assert "feature" in body
6281
6282
6283 @pytest.mark.anyio
6284 async def test_compare_page_no_auth_required(
6285 client: AsyncClient,
6286 db_session: AsyncSession,
6287 ) -> None:
6288 """Compare page is accessible without a JWT token."""
6289 await _make_repo(db_session)
6290 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6291 assert response.status_code == 200
6292
6293
6294 @pytest.mark.anyio
6295 async def test_compare_page_invalid_ref_404(
6296 client: AsyncClient,
6297 db_session: AsyncSession,
6298 ) -> None:
6299 """Compare path without '...' separator returns 404."""
6300 await _make_repo(db_session)
6301 response = await client.get("/musehub/ui/testuser/test-beats/compare/mainfeature")
6302 assert response.status_code == 404
6303
6304
6305 @pytest.mark.anyio
6306 async def test_compare_page_unknown_owner_404(
6307 client: AsyncClient,
6308 ) -> None:
6309 """Unknown owner/slug combination returns 404 on compare page."""
6310 response = await client.get("/musehub/ui/nobody/norepo/compare/main...feature")
6311 assert response.status_code == 404
6312
6313
6314 @pytest.mark.anyio
6315 async def test_compare_page_includes_radar(
6316 client: AsyncClient,
6317 db_session: AsyncSession,
6318 ) -> None:
6319 """Compare page SSR HTML contains all five musical dimension names (replaces JS radar).
6320
6321 The compare page now renders data server-side via a dimension table.
6322 Musical dimensions (Melodic, Harmonic, etc.) must appear in the HTML body
6323 before any client-side JavaScript runs.
6324 """
6325 await _make_repo(db_session)
6326 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6327 assert response.status_code == 200
6328 body = response.text
6329 assert "Melodic" in body
6330 assert "Harmonic" in body
6331
6332
6333 @pytest.mark.anyio
6334 async def test_compare_page_includes_piano_roll(
6335 client: AsyncClient,
6336 db_session: AsyncSession,
6337 ) -> None:
6338 """Compare page SSR HTML contains the dimension table (replaces piano roll JS panel).
6339
6340 The compare page now renders a dimension comparison table server-side.
6341 Both ref names must appear as column headers in the HTML.
6342 """
6343 await _make_repo(db_session)
6344 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6345 assert response.status_code == 200
6346 body = response.text
6347 assert "main" in body
6348 assert "feature" in body
6349 assert "Dimension" in body
6350
6351
6352 @pytest.mark.anyio
6353 async def test_compare_page_includes_emotion_diff(
6354 client: AsyncClient,
6355 db_session: AsyncSession,
6356 ) -> None:
6357 """Compare page SSR HTML contains change delta column (replaces emotion diff JS).
6358
6359 The dimension table includes a Change column showing delta values server-side.
6360 """
6361 await _make_repo(db_session)
6362 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6363 assert response.status_code == 200
6364 body = response.text
6365 assert "Change" in body
6366 assert "%" in body
6367
6368
6369 @pytest.mark.anyio
6370 async def test_compare_page_includes_commit_list(
6371 client: AsyncClient,
6372 db_session: AsyncSession,
6373 ) -> None:
6374 """Compare page SSR HTML contains dimension rows (replaces client-side commit list JS).
6375
6376 All five musical dimensions must appear as data rows in the server-rendered table.
6377 """
6378 await _make_repo(db_session)
6379 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6380 assert response.status_code == 200
6381 body = response.text
6382 assert "Rhythmic" in body
6383 assert "Structural" in body
6384 assert "Dynamic" in body
6385
6386
6387 @pytest.mark.anyio
6388 async def test_compare_page_includes_create_pr_button(
6389 client: AsyncClient,
6390 db_session: AsyncSession,
6391 ) -> None:
6392 """Compare page SSR HTML contains both ref names in the heading (replaces PR button CTA).
6393
6394 The SSR compare page shows the base and head refs in the page header.
6395 """
6396 await _make_repo(db_session)
6397 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6398 assert response.status_code == 200
6399 body = response.text
6400 assert "Compare" in body
6401 assert "main" in body
6402 assert "feature" in body
6403
6404
6405 @pytest.mark.anyio
6406 async def test_compare_json_response(
6407 client: AsyncClient,
6408 db_session: AsyncSession,
6409 ) -> None:
6410 """GET /musehub/ui/{owner}/{slug}/compare/{refs} returns HTML with SSR dimension data.
6411
6412 The compare page is now fully SSR — no JSON format negotiation.
6413 The response is always text/html containing the dimension table.
6414 """
6415 await _make_repo(db_session)
6416 response = await client.get("/musehub/ui/testuser/test-beats/compare/main...feature")
6417 assert response.status_code == 200
6418 assert "text/html" in response.headers["content-type"]
6419 body = response.text
6420 assert "Melodic" in body
6421 assert "main" in body
6422
6423
6424 # ---------------------------------------------------------------------------
6425 # Issue #208 — Branch list and tag browser tests
6426 # ---------------------------------------------------------------------------
6427
6428
6429 async def _make_repo_with_branches(
6430 db_session: AsyncSession,
6431 ) -> tuple[str, str, str]:
6432 """Seed a repo with two branches (main + feature) and return (repo_id, owner, slug)."""
6433 repo = MusehubRepo(
6434 name="branch-test",
6435 owner="testuser",
6436 slug="branch-test",
6437 visibility="private",
6438 owner_user_id="test-owner",
6439 )
6440 db_session.add(repo)
6441 await db_session.flush()
6442 repo_id = str(repo.repo_id)
6443
6444 main_branch = MusehubBranch(repo_id=repo_id, name="main", head_commit_id="aaa000")
6445 feat_branch = MusehubBranch(repo_id=repo_id, name="feat/jazz-bridge", head_commit_id="bbb111")
6446 db_session.add_all([main_branch, feat_branch])
6447
6448 # Two commits on main, one unique commit on feat/jazz-bridge
6449 now = datetime.now(UTC)
6450 c1 = MusehubCommit(
6451 commit_id="aaa000",
6452 repo_id=repo_id,
6453 branch="main",
6454 parent_ids=[],
6455 message="Initial commit",
6456 author="composer@muse.app",
6457 timestamp=now,
6458 )
6459 c2 = MusehubCommit(
6460 commit_id="aaa001",
6461 repo_id=repo_id,
6462 branch="main",
6463 parent_ids=["aaa000"],
6464 message="Add bridge",
6465 author="composer@muse.app",
6466 timestamp=now,
6467 )
6468 c3 = MusehubCommit(
6469 commit_id="bbb111",
6470 repo_id=repo_id,
6471 branch="feat/jazz-bridge",
6472 parent_ids=["aaa000"],
6473 message="Add jazz chord",
6474 author="composer@muse.app",
6475 timestamp=now,
6476 )
6477 db_session.add_all([c1, c2, c3])
6478 await db_session.commit()
6479 return repo_id, "testuser", "branch-test"
6480
6481
6482 async def _make_repo_with_releases(
6483 db_session: AsyncSession,
6484 ) -> tuple[str, str, str]:
6485 """Seed a repo with namespaced releases used as tags."""
6486 repo = MusehubRepo(
6487 name="tag-test",
6488 owner="testuser",
6489 slug="tag-test",
6490 visibility="private",
6491 owner_user_id="test-owner",
6492 )
6493 db_session.add(repo)
6494 await db_session.flush()
6495 repo_id = str(repo.repo_id)
6496
6497 now = datetime.now(UTC)
6498 releases = [
6499 MusehubRelease(
6500 repo_id=repo_id, tag="emotion:happy", title="Happy vibes", body="",
6501 commit_id="abc001", author="composer", created_at=now, download_urls={},
6502 ),
6503 MusehubRelease(
6504 repo_id=repo_id, tag="genre:jazz", title="Jazz release", body="",
6505 commit_id="abc002", author="composer", created_at=now, download_urls={},
6506 ),
6507 MusehubRelease(
6508 repo_id=repo_id, tag="v1.0", title="Version 1.0", body="",
6509 commit_id="abc003", author="composer", created_at=now, download_urls={},
6510 ),
6511 ]
6512 db_session.add_all(releases)
6513 await db_session.commit()
6514 return repo_id, "testuser", "tag-test"
6515
6516
6517 @pytest.mark.anyio
6518 async def test_branches_page_lists_all(
6519 client: AsyncClient,
6520 db_session: AsyncSession,
6521 ) -> None:
6522 """GET /musehub/ui/{owner}/{slug}/branches returns 200 HTML."""
6523 await _make_repo_with_branches(db_session)
6524 resp = await client.get("/musehub/ui/testuser/branch-test/branches")
6525 assert resp.status_code == 200
6526 assert "text/html" in resp.headers["content-type"]
6527 body = resp.text
6528 assert "Muse Hub" in body
6529 # Page-specific JS identifiers
6530 assert "branch-row" in body or "branches" in body.lower()
6531
6532
6533 @pytest.mark.anyio
6534 async def test_branches_default_marked(
6535 client: AsyncClient,
6536 db_session: AsyncSession,
6537 ) -> None:
6538 """JSON response marks the default branch with isDefault=true."""
6539 await _make_repo_with_branches(db_session)
6540 resp = await client.get(
6541 "/musehub/ui/testuser/branch-test/branches",
6542 headers={"Accept": "application/json"},
6543 )
6544 assert resp.status_code == 200
6545 data = resp.json()
6546 assert "branches" in data
6547 default_branches = [b for b in data["branches"] if b.get("isDefault")]
6548 assert len(default_branches) == 1
6549 assert default_branches[0]["name"] == "main"
6550
6551
6552 @pytest.mark.anyio
6553 async def test_branches_compare_link(
6554 client: AsyncClient,
6555 db_session: AsyncSession,
6556 ) -> None:
6557 """Branches page HTML contains compare link JavaScript."""
6558 await _make_repo_with_branches(db_session)
6559 resp = await client.get("/musehub/ui/testuser/branch-test/branches")
6560 assert resp.status_code == 200
6561 body = resp.text
6562 # The JS template must reference the compare URL pattern
6563 assert "compare" in body.lower()
6564
6565
6566 @pytest.mark.anyio
6567 async def test_branches_new_pr_button(
6568 client: AsyncClient,
6569 db_session: AsyncSession,
6570 ) -> None:
6571 """Branches page HTML contains New Pull Request link JavaScript."""
6572 await _make_repo_with_branches(db_session)
6573 resp = await client.get("/musehub/ui/testuser/branch-test/branches")
6574 assert resp.status_code == 200
6575 body = resp.text
6576 assert "Pull Request" in body
6577
6578
6579 @pytest.mark.anyio
6580 async def test_branches_json_response(
6581 client: AsyncClient,
6582 db_session: AsyncSession,
6583 ) -> None:
6584 """JSON response includes branches with ahead/behind counts and divergence placeholder."""
6585 await _make_repo_with_branches(db_session)
6586 resp = await client.get(
6587 "/musehub/ui/testuser/branch-test/branches?format=json",
6588 )
6589 assert resp.status_code == 200
6590 data = resp.json()
6591 assert "branches" in data
6592 assert "defaultBranch" in data
6593 assert data["defaultBranch"] == "main"
6594
6595 branches_by_name = {b["name"]: b for b in data["branches"]}
6596 assert "main" in branches_by_name
6597 assert "feat/jazz-bridge" in branches_by_name
6598
6599 main = branches_by_name["main"]
6600 assert main["isDefault"] is True
6601 assert main["aheadCount"] == 0
6602 assert main["behindCount"] == 0
6603
6604 feat = branches_by_name["feat/jazz-bridge"]
6605 assert feat["isDefault"] is False
6606 # feat has 1 unique commit (bbb111); main has 2 commits (aaa000, aaa001) not shared with feat
6607 assert feat["aheadCount"] == 1
6608 assert feat["behindCount"] == 2
6609
6610 # Divergence is a placeholder (all None)
6611 div = feat["divergence"]
6612 assert div["melodic"] is None
6613 assert div["harmonic"] is None
6614
6615
6616 @pytest.mark.anyio
6617 async def test_tags_page_lists_all(
6618 client: AsyncClient,
6619 db_session: AsyncSession,
6620 ) -> None:
6621 """GET /musehub/ui/{owner}/{slug}/tags returns 200 HTML."""
6622 await _make_repo_with_releases(db_session)
6623 resp = await client.get("/musehub/ui/testuser/tag-test/tags")
6624 assert resp.status_code == 200
6625 assert "text/html" in resp.headers["content-type"]
6626 body = resp.text
6627 assert "Muse Hub" in body
6628 assert "Tags" in body
6629
6630
6631 @pytest.mark.anyio
6632 async def test_tags_namespace_filter(
6633 client: AsyncClient,
6634 db_session: AsyncSession,
6635 ) -> None:
6636 """Tags page HTML includes namespace filter dropdown JavaScript."""
6637 await _make_repo_with_releases(db_session)
6638 resp = await client.get("/musehub/ui/testuser/tag-test/tags")
6639 assert resp.status_code == 200
6640 body = resp.text
6641 # Namespace filter select element is rendered by JS
6642 assert "ns-filter" in body or "namespace" in body.lower()
6643 # Namespace icons present
6644 assert "&#127768;" in body or "emotion" in body
6645
6646
6647 @pytest.mark.anyio
6648 async def test_tags_json_response(
6649 client: AsyncClient,
6650 db_session: AsyncSession,
6651 ) -> None:
6652 """JSON response returns TagListResponse with namespace grouping."""
6653 await _make_repo_with_releases(db_session)
6654 resp = await client.get(
6655 "/musehub/ui/testuser/tag-test/tags?format=json",
6656 )
6657 assert resp.status_code == 200
6658 data = resp.json()
6659 assert "tags" in data
6660 assert "namespaces" in data
6661
6662 # All three releases become tags
6663 assert len(data["tags"]) == 3
6664
6665 tags_by_name = {t["tag"]: t for t in data["tags"]}
6666 assert "emotion:happy" in tags_by_name
6667 assert "genre:jazz" in tags_by_name
6668 assert "v1.0" in tags_by_name
6669
6670 assert tags_by_name["emotion:happy"]["namespace"] == "emotion"
6671 assert tags_by_name["genre:jazz"]["namespace"] == "genre"
6672 assert tags_by_name["v1.0"]["namespace"] == "version"
6673
6674 # Namespaces are sorted
6675 assert sorted(data["namespaces"]) == data["namespaces"]
6676 assert "emotion" in data["namespaces"]
6677 assert "genre" in data["namespaces"]
6678 assert "version" in data["namespaces"]
6679
6680
6681
6682 # ---------------------------------------------------------------------------
6683 # Arrangement matrix page — # ---------------------------------------------------------------------------
6684
6685
6686 # ---------------------------------------------------------------------------
6687 # Piano roll page tests — # ---------------------------------------------------------------------------
6688
6689
6690 @pytest.mark.anyio
6691 async def test_arrange_page_returns_200(
6692 client: AsyncClient,
6693 db_session: AsyncSession,
6694 ) -> None:
6695 """GET /musehub/ui/{owner}/{slug}/arrange/{ref} returns 200 HTML without a JWT."""
6696 await _make_repo(db_session)
6697 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6698 assert response.status_code == 200
6699 assert "text/html" in response.headers["content-type"]
6700
6701
6702 @pytest.mark.anyio
6703 async def test_piano_roll_page_returns_200(
6704 client: AsyncClient,
6705 db_session: AsyncSession,
6706 ) -> None:
6707 """GET /musehub/ui/{owner}/{slug}/piano-roll/{ref} returns 200 HTML."""
6708 await _make_repo(db_session)
6709 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6710 assert response.status_code == 200
6711 assert "text/html" in response.headers["content-type"]
6712
6713
6714 @pytest.mark.anyio
6715 async def test_arrange_page_no_auth_required(
6716 client: AsyncClient,
6717 db_session: AsyncSession,
6718 ) -> None:
6719 """Arrangement matrix page is accessible without a JWT (auth handled client-side)."""
6720 await _make_repo(db_session)
6721 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6722 assert response.status_code == 200
6723 assert response.status_code != 401
6724
6725
6726 @pytest.mark.anyio
6727 async def test_arrange_page_contains_musehub(
6728 client: AsyncClient,
6729 db_session: AsyncSession,
6730 ) -> None:
6731 """Arrangement matrix page HTML shell contains 'Muse Hub' branding."""
6732 await _make_repo(db_session)
6733 response = await client.get("/musehub/ui/testuser/test-beats/arrange/abc1234")
6734 assert response.status_code == 200
6735 assert "Muse Hub" in response.text
6736
6737
6738 @pytest.mark.anyio
6739 async def test_arrange_page_contains_grid_js(
6740 client: AsyncClient,
6741 db_session: AsyncSession,
6742 ) -> None:
6743 """Arrangement matrix page embeds the grid rendering JS (renderMatrix or arrange)."""
6744 await _make_repo(db_session)
6745 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6746 assert response.status_code == 200
6747 body = response.text
6748 assert "renderMatrix" in body or "arrange" in body.lower()
6749
6750
6751 @pytest.mark.anyio
6752 async def test_arrange_page_contains_density_logic(
6753 client: AsyncClient,
6754 db_session: AsyncSession,
6755 ) -> None:
6756 """Arrangement matrix page includes density colour logic."""
6757 await _make_repo(db_session)
6758 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6759 assert response.status_code == 200
6760 body = response.text
6761 assert "density" in body.lower() or "noteDensity" in body
6762
6763
6764 @pytest.mark.anyio
6765 async def test_arrange_page_contains_token_form(
6766 client: AsyncClient,
6767 db_session: AsyncSession,
6768 ) -> None:
6769 """Arrangement matrix page includes the JWT token form for client-side auth."""
6770 await _make_repo(db_session)
6771 response = await client.get("/musehub/ui/testuser/test-beats/arrange/HEAD")
6772 assert response.status_code == 200
6773 body = response.text
6774 assert 'id="token-form"' in body
6775 assert "musehub.js" in body
6776
6777
6778 @pytest.mark.anyio
6779 async def test_arrange_page_unknown_repo_returns_404(
6780 client: AsyncClient,
6781 db_session: AsyncSession,
6782 ) -> None:
6783 """GET /musehub/ui/{unknown}/{slug}/arrange/{ref} returns 404 for unknown repos."""
6784 response = await client.get("/musehub/ui/unknown-user/no-such-repo/arrange/HEAD")
6785 assert response.status_code == 404
6786
6787
6788 @pytest.mark.anyio
6789 async def test_commit_detail_unknown_format_param_returns_html(
6790 client: AsyncClient,
6791 db_session: AsyncSession,
6792 ) -> None:
6793 """GET commit detail page ignores ?format=json — SSR always returns HTML."""
6794 await _seed_commit_detail_fixtures(db_session)
6795 sha = "bbbb1111222233334444555566667777bbbbcccc"
6796 response = await client.get(
6797 f"/musehub/ui/testuser/commit-detail-test/commits/{sha}?format=json"
6798 )
6799 assert response.status_code == 200
6800 assert "text/html" in response.headers["content-type"]
6801 # SSR commit page — commit message appears in body
6802 assert "feat(keys)" in response.text
6803
6804
6805 @pytest.mark.anyio
6806 async def test_commit_detail_wavesurfer_js_conditional_on_audio_url(
6807 client: AsyncClient,
6808 db_session: AsyncSession,
6809 ) -> None:
6810 """WaveSurfer JS block is only present when audio_url is set (replaces musicalMeta JS checks)."""
6811 await _seed_commit_detail_fixtures(db_session)
6812 sha = "bbbb1111222233334444555566667777bbbbcccc"
6813 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
6814 assert response.status_code == 200
6815 body = response.text
6816 # The child commit has no snapshot_id in _seed_commit_detail_fixtures → no WaveSurfer
6817 assert "commit-waveform" not in body
6818 # WaveSurfer script only loaded when audio is present — not here
6819 assert "wavesurfer.min.js" not in body
6820
6821
6822 @pytest.mark.anyio
6823 async def test_commit_detail_nav_has_parent_link(
6824 client: AsyncClient,
6825 db_session: AsyncSession,
6826 ) -> None:
6827 """Commit detail page navigation includes the parent commit link (SSR)."""
6828 await _seed_commit_detail_fixtures(db_session)
6829 sha = "bbbb1111222233334444555566667777bbbbcccc"
6830 response = await client.get(f"/musehub/ui/testuser/commit-detail-test/commits/{sha}")
6831 assert response.status_code == 200
6832 body = response.text
6833 # SSR renders parent commit link when parent_ids is non-empty
6834 assert "Parent Commit" in body
6835 # Parent SHA abbreviated to 8 chars in href
6836 assert "aaaa0000" in body
6837
6838
6839 @pytest.mark.anyio
6840 async def test_piano_roll_page_no_auth_required(
6841 client: AsyncClient,
6842 db_session: AsyncSession,
6843 ) -> None:
6844 """Piano roll UI page is accessible without a JWT token."""
6845 await _make_repo(db_session)
6846 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6847 assert response.status_code == 200
6848
6849
6850 @pytest.mark.anyio
6851 async def test_piano_roll_page_loads_piano_roll_js(
6852 client: AsyncClient,
6853 db_session: AsyncSession,
6854 ) -> None:
6855 """Piano roll page references piano-roll.js script."""
6856 await _make_repo(db_session)
6857 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6858 assert response.status_code == 200
6859 assert "piano-roll.js" in response.text
6860
6861
6862 @pytest.mark.anyio
6863 async def test_piano_roll_page_contains_canvas(
6864 client: AsyncClient,
6865 db_session: AsyncSession,
6866 ) -> None:
6867 """Piano roll page embeds a canvas element for rendering."""
6868 await _make_repo(db_session)
6869 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6870 assert response.status_code == 200
6871 body = response.text
6872 assert "PianoRoll" in body or "piano-canvas" in body or "piano-roll.js" in body
6873
6874
6875 @pytest.mark.anyio
6876 async def test_piano_roll_page_has_token_form(
6877 client: AsyncClient,
6878 db_session: AsyncSession,
6879 ) -> None:
6880 """Piano roll page includes the JWT token form for unauthenticated visitors."""
6881 await _make_repo(db_session)
6882 response = await client.get("/musehub/ui/testuser/test-beats/piano-roll/main")
6883 assert response.status_code == 200
6884 assert 'id="token-form"' in response.text
6885 assert "musehub.js" in response.text
6886
6887
6888 @pytest.mark.anyio
6889 async def test_piano_roll_page_unknown_repo_404(
6890 client: AsyncClient,
6891 db_session: AsyncSession,
6892 ) -> None:
6893 """Piano roll page for an unknown repo returns 404."""
6894 response = await client.get("/musehub/ui/nobody/no-repo/piano-roll/main")
6895 assert response.status_code == 404
6896
6897
6898 @pytest.mark.anyio
6899 async def test_arrange_tab_in_repo_nav(
6900 client: AsyncClient,
6901 db_session: AsyncSession,
6902 ) -> None:
6903 """Repo home page navigation includes an 'Arrange' tab link."""
6904 await _make_repo(db_session)
6905 response = await client.get("/musehub/ui/testuser/test-beats")
6906 assert response.status_code == 200
6907 assert "Arrange" in response.text or "arrange" in response.text
6908
6909
6910 @pytest.mark.anyio
6911 async def test_piano_roll_track_page_returns_200(
6912 client: AsyncClient,
6913 db_session: AsyncSession,
6914 ) -> None:
6915 """GET /piano-roll/{ref}/{path} (single track) returns 200."""
6916 await _make_repo(db_session)
6917 response = await client.get(
6918 "/musehub/ui/testuser/test-beats/piano-roll/main/tracks/bass.mid"
6919 )
6920 assert response.status_code == 200
6921
6922
6923 @pytest.mark.anyio
6924 async def test_piano_roll_track_page_embeds_path(
6925 client: AsyncClient,
6926 db_session: AsyncSession,
6927 ) -> None:
6928 """Single-track piano roll page embeds the MIDI file path in the JS context."""
6929 await _make_repo(db_session)
6930 response = await client.get(
6931 "/musehub/ui/testuser/test-beats/piano-roll/main/tracks/bass.mid"
6932 )
6933 assert response.status_code == 200
6934 assert "tracks/bass.mid" in response.text
6935
6936
6937 @pytest.mark.anyio
6938 async def test_piano_roll_js_served(client: AsyncClient) -> None:
6939 """GET /musehub/static/piano-roll.js returns 200 JavaScript."""
6940 response = await client.get("/musehub/static/piano-roll.js")
6941 assert response.status_code == 200
6942 assert "javascript" in response.headers.get("content-type", "")
6943
6944
6945 @pytest.mark.anyio
6946 async def test_piano_roll_js_contains_renderer(client: AsyncClient) -> None:
6947 """piano-roll.js exports the PianoRoll.render function."""
6948 response = await client.get("/musehub/static/piano-roll.js")
6949 assert response.status_code == 200
6950 body = response.text
6951 assert "PianoRoll" in body
6952 assert "render" in body
6953
6954
6955
6956 async def _seed_blob_fixtures(db_session: AsyncSession) -> str:
6957 """Seed a public repo with a branch and typed objects for blob viewer tests.
6958
6959 Creates:
6960 - repo: testuser/blob-test (public)
6961 - branch: main
6962 - objects: tracks/bass.mid, tracks/keys.mp3, metadata.json, cover.webp
6963
6964 Returns repo_id.
6965 """
6966 repo = MusehubRepo(
6967 name="blob-test",
6968 owner="testuser",
6969 slug="blob-test",
6970 visibility="public",
6971 owner_user_id="test-owner",
6972 )
6973 db_session.add(repo)
6974 await db_session.flush()
6975
6976 commit = MusehubCommit(
6977 commit_id="blobdeadbeef12",
6978 repo_id=str(repo.repo_id),
6979 message="add blob fixtures",
6980 branch="main",
6981 author="testuser",
6982 timestamp=datetime.now(tz=UTC),
6983 )
6984 db_session.add(commit)
6985
6986 branch = MusehubBranch(
6987 repo_id=str(repo.repo_id),
6988 name="main",
6989 head_commit_id="blobdeadbeef12",
6990 )
6991 db_session.add(branch)
6992
6993 for path, size in [
6994 ("tracks/bass.mid", 2048),
6995 ("tracks/keys.mp3", 8192),
6996 ("metadata.json", 512),
6997 ("cover.webp", 4096),
6998 ]:
6999 obj = MusehubObject(
7000 object_id=f"sha256:blob_{path.replace('/', '_')}",
7001 repo_id=str(repo.repo_id),
7002 path=path,
7003 size_bytes=size,
7004 disk_path=f"/tmp/blob_{path.replace('/', '_')}",
7005 )
7006 db_session.add(obj)
7007
7008 await db_session.commit()
7009 return str(repo.repo_id)
7010
7011
7012
7013 @pytest.mark.anyio
7014 async def test_blob_404_unknown_path(
7015 client: AsyncClient,
7016 db_session: AsyncSession,
7017 ) -> None:
7018 """GET /api/v1/musehub/repos/{repo_id}/blob/{ref}/{path} returns 404 for unknown path."""
7019 repo_id = await _seed_blob_fixtures(db_session)
7020 response = await client.get(f"/api/v1/musehub/repos/{repo_id}/blob/main/does/not/exist.mid")
7021 assert response.status_code == 404
7022
7023
7024 @pytest.mark.anyio
7025 async def test_blob_image_shows_inline(
7026 client: AsyncClient,
7027 db_session: AsyncSession,
7028 ) -> None:
7029 """Blob page for .webp file includes <img> rendering logic in the template JS."""
7030 await _seed_blob_fixtures(db_session)
7031 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/cover.webp")
7032 assert response.status_code == 200
7033 body = response.text
7034 # JS template emits <img> for image file type
7035 assert "<img" in body or "blob-img" in body
7036 assert "cover.webp" in body
7037
7038
7039 @pytest.mark.anyio
7040 async def test_blob_json_response(
7041 client: AsyncClient,
7042 db_session: AsyncSession,
7043 ) -> None:
7044 """GET /api/v1/musehub/repos/{repo_id}/blob/{ref}/{path} returns BlobMetaResponse JSON."""
7045 repo_id = await _seed_blob_fixtures(db_session)
7046 response = await client.get(
7047 f"/api/v1/musehub/repos/{repo_id}/blob/main/tracks/bass.mid"
7048 )
7049 assert response.status_code == 200
7050 data = response.json()
7051 assert data["path"] == "tracks/bass.mid"
7052 assert data["filename"] == "bass.mid"
7053 assert data["sizeBytes"] == 2048
7054 assert data["fileType"] == "midi"
7055 assert data["sha"].startswith("sha256:")
7056 assert "/raw/" in data["rawUrl"]
7057 # MIDI is binary — no content_text
7058 assert data["contentText"] is None
7059 @pytest.mark.anyio
7060 async def test_blob_json_syntax_highlighted(
7061 client: AsyncClient,
7062 db_session: AsyncSession,
7063 ) -> None:
7064 """Blob page for .json file includes syntax-highlighting logic in the template JS."""
7065 await _seed_blob_fixtures(db_session)
7066 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/metadata.json")
7067 assert response.status_code == 200
7068 body = response.text
7069 # highlightJson function must be present in the template script
7070 assert "highlightJson" in body or "json-key" in body
7071 assert "metadata.json" in body
7072
7073
7074 @pytest.mark.anyio
7075 async def test_blob_midi_shows_piano_roll_link(
7076 client: AsyncClient,
7077 db_session: AsyncSession,
7078 ) -> None:
7079 """GET /musehub/ui/{owner}/{repo}/blob/{ref}/{path} returns 200 HTML for a .mid file.
7080
7081 The template's client-side JS must reference the piano roll URL pattern so that
7082 clicking the page in a browser navigates to the piano roll viewer.
7083 """
7084 await _seed_blob_fixtures(db_session)
7085 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/tracks/bass.mid")
7086 assert response.status_code == 200
7087 assert "text/html" in response.headers["content-type"]
7088 body = response.text
7089 # JS in the template constructs piano-roll URLs for MIDI files
7090 assert "piano-roll" in body or "Piano Roll" in body
7091 # Filename is embedded in the page context
7092 assert "bass.mid" in body
7093
7094
7095 @pytest.mark.anyio
7096 async def test_blob_mp3_shows_audio_player(
7097 client: AsyncClient,
7098 db_session: AsyncSession,
7099 ) -> None:
7100 """Blob page for .mp3 file includes <audio> rendering logic in the template JS."""
7101 await _seed_blob_fixtures(db_session)
7102 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/tracks/keys.mp3")
7103 assert response.status_code == 200
7104 body = response.text
7105 # JS template emits <audio> element for audio file type
7106 assert "<audio" in body or "blob-audio" in body
7107 assert "keys.mp3" in body
7108
7109
7110 @pytest.mark.anyio
7111 async def test_blob_raw_button(
7112 client: AsyncClient,
7113 db_session: AsyncSession,
7114 ) -> None:
7115 """Blob page JS constructs a Raw download link via the /raw/ endpoint."""
7116 await _seed_blob_fixtures(db_session)
7117 response = await client.get("/musehub/ui/testuser/blob-test/blob/main/tracks/bass.mid")
7118 assert response.status_code == 200
7119 body = response.text
7120 # JS constructs raw URL — the string '/raw/' must appear in the template script
7121 assert "/raw/" in body
7122
7123
7124 @pytest.mark.anyio
7125 async def test_score_page_contains_legend(
7126 client: AsyncClient,
7127 db_session: AsyncSession,
7128 ) -> None:
7129 """Score page includes a legend for note symbols."""
7130 await _make_repo(db_session)
7131 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7132 assert response.status_code == 200
7133 body = response.text
7134 assert "legend" in body or "Note" in body
7135
7136
7137 @pytest.mark.anyio
7138 async def test_score_page_contains_score_meta(
7139 client: AsyncClient,
7140 db_session: AsyncSession,
7141 ) -> None:
7142 """Score page embeds a score metadata panel (key/tempo/time signature)."""
7143 await _make_repo(db_session)
7144 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7145 assert response.status_code == 200
7146 body = response.text
7147 assert "score-meta" in body
7148
7149
7150 @pytest.mark.anyio
7151 async def test_score_page_contains_staff_container(
7152 client: AsyncClient,
7153 db_session: AsyncSession,
7154 ) -> None:
7155 """Score page embeds the SVG staff container markup."""
7156 await _make_repo(db_session)
7157 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7158 assert response.status_code == 200
7159 body = response.text
7160 assert "staff-container" in body or "staves" in body
7161
7162
7163 @pytest.mark.anyio
7164 async def test_score_page_contains_track_selector(
7165 client: AsyncClient,
7166 db_session: AsyncSession,
7167 ) -> None:
7168 """Score page embeds a track selector element."""
7169 await _make_repo(db_session)
7170 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7171 assert response.status_code == 200
7172 body = response.text
7173 assert "track-selector" in body
7174
7175
7176 @pytest.mark.anyio
7177 async def test_score_page_no_auth_required(
7178 client: AsyncClient,
7179 db_session: AsyncSession,
7180 ) -> None:
7181 """Score UI page must be accessible without an Authorization header."""
7182 await _make_repo(db_session)
7183 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7184 assert response.status_code == 200
7185 assert response.status_code != 401
7186
7187
7188 @pytest.mark.anyio
7189 async def test_score_page_renders(
7190 client: AsyncClient,
7191 db_session: AsyncSession,
7192 ) -> None:
7193 """GET /musehub/ui/{owner}/{slug}/score/{ref} returns 200 HTML."""
7194 await _make_repo(db_session)
7195 response = await client.get("/musehub/ui/testuser/test-beats/score/main")
7196 assert response.status_code == 200
7197 assert "text/html" in response.headers["content-type"]
7198 body = response.text
7199 assert "Muse Hub" in body
7200
7201
7202 @pytest.mark.anyio
7203 async def test_score_part_page_includes_path(
7204 client: AsyncClient,
7205 db_session: AsyncSession,
7206 ) -> None:
7207 """Single-part score page injects the path segment into page data."""
7208 await _make_repo(db_session)
7209 response = await client.get("/musehub/ui/testuser/test-beats/score/main/piano")
7210 assert response.status_code == 200
7211 body = response.text
7212 # scorePath JS variable should be set to the path segment
7213 assert "piano" in body
7214
7215
7216 @pytest.mark.anyio
7217 async def test_score_part_page_renders(
7218 client: AsyncClient,
7219 db_session: AsyncSession,
7220 ) -> None:
7221 """GET /musehub/ui/{owner}/{slug}/score/{ref}/{path} returns 200 HTML."""
7222 await _make_repo(db_session)
7223 response = await client.get("/musehub/ui/testuser/test-beats/score/main/piano")
7224 assert response.status_code == 200
7225 assert "text/html" in response.headers["content-type"]
7226 body = response.text
7227 assert "Muse Hub" in body
7228
7229
7230 @pytest.mark.anyio
7231 async def test_score_unknown_repo_404(
7232 client: AsyncClient,
7233 db_session: AsyncSession,
7234 ) -> None:
7235 """GET /musehub/ui/{unknown}/{slug}/score/{ref} returns 404."""
7236 response = await client.get("/musehub/ui/nobody/no-beats/score/main")
7237 assert response.status_code == 404
7238
7239
7240 # ---------------------------------------------------------------------------
7241 # Arrangement matrix page — # ---------------------------------------------------------------------------
7242
7243
7244 # ---------------------------------------------------------------------------
7245 # Piano roll page tests — # ---------------------------------------------------------------------------
7246
7247
7248 @pytest.mark.anyio
7249 async def test_ui_commit_page_artifact_auth_uses_blob_proxy(
7250 client: AsyncClient,
7251 db_session: AsyncSession,
7252 ) -> None:
7253 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7254
7255 The pre-SSR blob-proxy artifact pattern no longer applies — artifacts are loaded
7256 via the API. Non-existent commit SHAs now return 404 rather than an empty JS shell.
7257 """
7258 await _make_repo(db_session)
7259 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7260 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
7261 assert response.status_code == 404
7262
7263
7264 # ---------------------------------------------------------------------------
7265 # Reaction bars — # ---------------------------------------------------------------------------
7266
7267
7268 @pytest.mark.anyio
7269 async def test_reaction_bar_js_in_musehub_js(
7270 client: AsyncClient,
7271 db_session: AsyncSession,
7272 ) -> None:
7273 """musehub.js must define loadReactions and toggleReaction for all detail pages."""
7274 response = await client.get("/musehub/static/musehub.js")
7275 assert response.status_code == 200
7276 body = response.text
7277 assert "loadReactions" in body
7278 assert "toggleReaction" in body
7279 assert "REACTION_BAR_EMOJIS" in body
7280
7281
7282 @pytest.mark.anyio
7283 async def test_reaction_bar_emojis_in_musehub_js(
7284 client: AsyncClient,
7285 db_session: AsyncSession,
7286 ) -> None:
7287 """musehub.js reaction bar must include all 8 required emojis."""
7288 response = await client.get("/musehub/static/musehub.js")
7289 assert response.status_code == 200
7290 body = response.text
7291 for emoji in ["🔥", "❤️", "👏", "✨", "🎵", "🎸", "🎹", "🥁"]:
7292 assert emoji in body, f"Emoji {emoji!r} missing from musehub.js"
7293
7294
7295 @pytest.mark.anyio
7296 async def test_reaction_bar_commit_page_has_load_call(
7297 client: AsyncClient,
7298 db_session: AsyncSession,
7299 ) -> None:
7300 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7301
7302 Reactions are loaded via the API; the reaction bar is no longer a JS-only element
7303 in the SSR commit_detail.html template. Non-existent commits return 404.
7304 """
7305 await _make_repo(db_session)
7306 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7307 response = await client.get(f"/musehub/ui/testuser/test-beats/commits/{commit_id}")
7308 assert response.status_code == 404
7309
7310
7311 @pytest.mark.anyio
7312 async def test_reaction_bar_pr_detail_has_load_call(
7313 client: AsyncClient,
7314 db_session: AsyncSession,
7315 ) -> None:
7316 """PR detail page must call loadReactions for target_type 'pull_request'."""
7317 await _make_repo(db_session)
7318 pr_id = "some-pr-uuid-1234"
7319 response = await client.get(f"/musehub/ui/testuser/test-beats/pulls/{pr_id}")
7320 assert response.status_code == 200
7321 body = response.text
7322 assert "loadReactions" in body
7323 assert "pr-reactions" in body
7324
7325
7326 @pytest.mark.anyio
7327 async def test_reaction_bar_issue_detail_has_load_call(
7328 client: AsyncClient,
7329 db_session: AsyncSession,
7330 ) -> None:
7331 """Issue detail page must call loadReactions for target_type 'issue'."""
7332 await _make_repo(db_session)
7333 response = await client.get("/musehub/ui/testuser/test-beats/issues/1")
7334 assert response.status_code == 200
7335 body = response.text
7336 assert "loadReactions" in body
7337 assert "issue-reactions" in body
7338
7339
7340 @pytest.mark.anyio
7341 async def test_reaction_bar_release_detail_has_load_call(
7342 client: AsyncClient,
7343 db_session: AsyncSession,
7344 ) -> None:
7345 """Release detail page must call loadReactions for target_type 'release'."""
7346 await _make_repo(db_session)
7347 response = await client.get("/musehub/ui/testuser/test-beats/releases/v1.0")
7348 assert response.status_code == 200
7349 body = response.text
7350 assert "loadReactions" in body
7351 assert "release-reactions" in body
7352
7353
7354 @pytest.mark.anyio
7355 async def test_reaction_bar_session_detail_has_load_call(
7356 client: AsyncClient,
7357 db_session: AsyncSession,
7358 ) -> None:
7359 """Session detail page must call loadReactions for target_type 'session'."""
7360 await _make_repo(db_session)
7361 session_id = "some-session-uuid-1234"
7362 response = await client.get(f"/musehub/ui/testuser/test-beats/sessions/{session_id}")
7363 assert response.status_code == 200
7364 body = response.text
7365 assert "loadReactions" in body
7366 assert "session-reactions" in body
7367
7368
7369 @pytest.mark.anyio
7370 async def test_reaction_api_allows_new_emojis(
7371 client: AsyncClient,
7372 db_session: AsyncSession,
7373 ) -> None:
7374 """POST /reactions with 👏 and 🎹 (new emojis) must be accepted (not 400)."""
7375 from musehub.db.musehub_models import MusehubRepo
7376 repo = MusehubRepo(
7377 name="reaction-test",
7378 owner="testuser",
7379 slug="reaction-test",
7380 visibility="public",
7381 owner_user_id="reaction-owner",
7382 )
7383 db_session.add(repo)
7384 await db_session.commit()
7385 await db_session.refresh(repo)
7386 repo_id = str(repo.repo_id)
7387
7388 token_headers = {"Authorization": "Bearer test-token"}
7389
7390 for emoji in ["👏", "🎹"]:
7391 response = await client.post(
7392 f"/api/v1/musehub/repos/{repo_id}/reactions",
7393 json={"target_type": "commit", "target_id": "abc123", "emoji": emoji},
7394 headers=token_headers,
7395 )
7396 assert response.status_code not in (400, 422), (
7397 f"Emoji {emoji!r} rejected by API: {response.status_code} {response.text}"
7398 )
7399
7400
7401 @pytest.mark.anyio
7402 async def test_reaction_api_allows_release_and_session_target_types(
7403 client: AsyncClient,
7404 db_session: AsyncSession,
7405 ) -> None:
7406 """POST /reactions must accept 'release' and 'session' as target_type values.
7407
7408 These target types were added to support reaction bars on
7409 release_detail and session_detail pages.
7410 """
7411 from musehub.db.musehub_models import MusehubRepo
7412 repo = MusehubRepo(
7413 name="target-type-test",
7414 owner="testuser",
7415 slug="target-type-test",
7416 visibility="public",
7417 owner_user_id="target-type-owner",
7418 )
7419 db_session.add(repo)
7420 await db_session.commit()
7421 await db_session.refresh(repo)
7422 repo_id = str(repo.repo_id)
7423
7424 token_headers = {"Authorization": "Bearer test-token"}
7425
7426 for target_type in ["release", "session"]:
7427 response = await client.post(
7428 f"/api/v1/musehub/repos/{repo_id}/reactions",
7429 json={"target_type": target_type, "target_id": "some-id", "emoji": "🔥"},
7430 headers=token_headers,
7431 )
7432 assert response.status_code not in (400, 422), (
7433 f"target_type {target_type!r} rejected: {response.status_code} {response.text}"
7434 )
7435
7436
7437 @pytest.mark.anyio
7438 async def test_reaction_bar_css_loaded_on_detail_pages(
7439 client: AsyncClient,
7440 db_session: AsyncSession,
7441 ) -> None:
7442 """Detail pages must load components.css which contains .reaction-bar styles."""
7443 await _make_repo(db_session)
7444 pages = [
7445 "/musehub/ui/testuser/test-beats/pulls/pr-uuid-abc",
7446 "/musehub/ui/testuser/test-beats/issues/1",
7447 "/musehub/ui/testuser/test-beats/releases/v1.0",
7448 "/musehub/ui/testuser/test-beats/sessions/session-uuid-abc",
7449 ]
7450 for page in pages:
7451 response = await client.get(page)
7452 assert response.status_code == 200
7453 assert "components.css" in response.text, f"components.css missing from {page}"
7454
7455
7456 @pytest.mark.anyio
7457 async def test_reaction_bar_components_css_has_styles(
7458 client: AsyncClient,
7459 db_session: AsyncSession,
7460 ) -> None:
7461 """components.css must define .reaction-bar and .reaction-btn CSS classes."""
7462 response = await client.get("/musehub/static/components.css")
7463 assert response.status_code == 200
7464 body = response.text
7465 assert ".reaction-bar" in body
7466 assert ".reaction-btn" in body
7467 assert ".reaction-btn--active" in body
7468 assert ".reaction-count" in body
7469
7470
7471 # ---------------------------------------------------------------------------
7472 # Feed page tests — (rich event cards)
7473 # ---------------------------------------------------------------------------
7474
7475
7476 @pytest.mark.anyio
7477 async def test_feed_page_returns_200(
7478 client: AsyncClient,
7479 db_session: AsyncSession,
7480 ) -> None:
7481 """GET /musehub/ui/feed returns 200 HTML without requiring a JWT."""
7482 response = await client.get("/musehub/ui/feed")
7483 assert response.status_code == 200
7484 assert "text/html" in response.headers["content-type"]
7485 assert "Activity Feed" in response.text
7486
7487
7488 @pytest.mark.anyio
7489 async def test_feed_page_no_raw_json_payload(
7490 client: AsyncClient,
7491 db_session: AsyncSession,
7492 ) -> None:
7493 """Feed page must not render raw JSON.stringify of notification payload.
7494
7495 Regression guard: the old implementation called
7496 JSON.stringify(item.payload) directly into the DOM, exposing raw JSON
7497 to users. The new rich card templates must not do this.
7498 """
7499 response = await client.get("/musehub/ui/feed")
7500 assert response.status_code == 200
7501 body = response.text
7502 assert "JSON.stringify(item.payload" not in body
7503 assert "JSON.stringify(item" not in body
7504
7505
7506 @pytest.mark.anyio
7507 async def test_feed_page_has_event_meta_for_all_types(
7508 client: AsyncClient,
7509 db_session: AsyncSession,
7510 ) -> None:
7511 """Feed page must define EVENT_META entries for all 8 notification event types."""
7512 response = await client.get("/musehub/ui/feed")
7513 assert response.status_code == 200
7514 body = response.text
7515 for event_type in (
7516 "comment",
7517 "mention",
7518 "pr_opened",
7519 "pr_merged",
7520 "issue_opened",
7521 "issue_closed",
7522 "new_commit",
7523 "new_follower",
7524 ):
7525 assert event_type in body, f"EVENT_META missing entry for '{event_type}'"
7526
7527
7528 @pytest.mark.anyio
7529 async def test_feed_page_has_data_notif_id_attribute(
7530 client: AsyncClient,
7531 db_session: AsyncSession,
7532 ) -> None:
7533 """Each event card must carry a data-notif-id attribute.
7534
7535 This attribute is the hook that (mark-as-read UX) will use to
7536 attach action buttons to each card without restructuring the DOM.
7537 """
7538 response = await client.get("/musehub/ui/feed")
7539 assert response.status_code == 200
7540 assert "data-notif-id" in response.text
7541
7542
7543 @pytest.mark.anyio
7544 async def test_feed_page_has_unread_indicator(
7545 client: AsyncClient,
7546 db_session: AsyncSession,
7547 ) -> None:
7548 """Feed page must include logic to highlight unread cards with a left border."""
7549 response = await client.get("/musehub/ui/feed")
7550 assert response.status_code == 200
7551 body = response.text
7552 assert "is_read" in body
7553 assert "color-accent" in body
7554
7555
7556 @pytest.mark.anyio
7557 async def test_feed_page_has_actor_avatar_logic(
7558 client: AsyncClient,
7559 db_session: AsyncSession,
7560 ) -> None:
7561 """Feed page must render actor avatars using the actorHsl / actorAvatar helpers."""
7562 response = await client.get("/musehub/ui/feed")
7563 assert response.status_code == 200
7564 body = response.text
7565 assert "actorHsl" in body
7566 assert "actorAvatar" in body
7567
7568
7569 @pytest.mark.anyio
7570 async def test_feed_page_has_relative_timestamp(
7571 client: AsyncClient,
7572 db_session: AsyncSession,
7573 ) -> None:
7574 """Feed page must call fmtRelative to render timestamps in a human-readable form."""
7575 response = await client.get("/musehub/ui/feed")
7576 assert response.status_code == 200
7577 assert "fmtRelative" in response.text
7578
7579
7580 # ---------------------------------------------------------------------------
7581 # Mark-as-read UX tests — # ---------------------------------------------------------------------------
7582
7583
7584 @pytest.mark.anyio
7585 async def test_feed_page_has_mark_one_read_function(
7586 client: AsyncClient,
7587 db_session: AsyncSession,
7588 ) -> None:
7589 """Feed page must define markOneRead() for per-notification mark-as-read."""
7590 response = await client.get("/musehub/ui/feed")
7591 assert response.status_code == 200
7592 assert "markOneRead" in response.text
7593
7594
7595 @pytest.mark.anyio
7596 async def test_feed_page_has_mark_all_read_function(
7597 client: AsyncClient,
7598 db_session: AsyncSession,
7599 ) -> None:
7600 """Feed page must define markAllRead() for bulk mark-as-read."""
7601 response = await client.get("/musehub/ui/feed")
7602 assert response.status_code == 200
7603 assert "markAllRead" in response.text
7604
7605
7606 @pytest.mark.anyio
7607 async def test_feed_page_has_decrement_nav_badge_function(
7608 client: AsyncClient,
7609 db_session: AsyncSession,
7610 ) -> None:
7611 """Feed page must define decrementNavBadge() to keep the nav badge in sync."""
7612 response = await client.get("/musehub/ui/feed")
7613 assert response.status_code == 200
7614 assert "decrementNavBadge" in response.text
7615
7616
7617 @pytest.mark.anyio
7618 async def test_feed_page_mark_read_btn_targets_notification_endpoint(
7619 client: AsyncClient,
7620 db_session: AsyncSession,
7621 ) -> None:
7622 """markOneRead() must call POST /notifications/{notif_id}/read."""
7623 response = await client.get("/musehub/ui/feed")
7624 assert response.status_code == 200
7625 body = response.text
7626 assert "/notifications/" in body
7627 assert "mark-read-btn" in body
7628
7629
7630 @pytest.mark.anyio
7631 async def test_feed_page_mark_all_btn_targets_read_all_endpoint(
7632 client: AsyncClient,
7633 db_session: AsyncSession,
7634 ) -> None:
7635 """markAllRead() must call POST /notifications/read-all."""
7636 response = await client.get("/musehub/ui/feed")
7637 assert response.status_code == 200
7638 assert "read-all" in response.text
7639
7640
7641 @pytest.mark.anyio
7642 async def test_feed_page_mark_all_btn_present_in_template(
7643 client: AsyncClient,
7644 db_session: AsyncSession,
7645 ) -> None:
7646 """Feed page must render a 'Mark all as read' button element."""
7647 response = await client.get("/musehub/ui/feed")
7648 assert response.status_code == 200
7649 assert "mark-all-read-btn" in response.text
7650
7651
7652 @pytest.mark.anyio
7653 async def test_feed_page_mark_read_updates_nav_badge(
7654 client: AsyncClient,
7655 db_session: AsyncSession,
7656 ) -> None:
7657 """After marking all as read, page logic must update nav-notif-badge to hidden."""
7658 response = await client.get("/musehub/ui/feed")
7659 assert response.status_code == 200
7660 body = response.text
7661 assert "nav-notif-badge" in body
7662 assert "decrementNavBadge" in body
7663
7664
7665 # ---------------------------------------------------------------------------
7666 # Per-dimension analysis detail pages
7667 # ---------------------------------------------------------------------------
7668
7669
7670 @pytest.mark.anyio
7671 async def test_key_analysis_page_renders(
7672 client: AsyncClient,
7673 db_session: AsyncSession,
7674 ) -> None:
7675 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/key returns 200 HTML."""
7676 await _make_repo(db_session)
7677 ref = "abc1234567890abcdef"
7678 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/key")
7679 assert response.status_code == 200
7680 assert "text/html" in response.headers["content-type"]
7681
7682
7683 @pytest.mark.anyio
7684 async def test_key_analysis_page_no_auth_required(
7685 client: AsyncClient,
7686 db_session: AsyncSession,
7687 ) -> None:
7688 """Key analysis page must be accessible without a JWT (HTML shell handles auth)."""
7689 await _make_repo(db_session)
7690 ref = "deadbeef1234"
7691 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/key")
7692 assert response.status_code != 401
7693 assert response.status_code == 200
7694
7695
7696 @pytest.mark.anyio
7697 async def test_key_analysis_page_contains_key_data_labels(
7698 client: AsyncClient,
7699 db_session: AsyncSession,
7700 ) -> None:
7701 """Key page must contain tonic, mode, relative key, and confidence UI elements."""
7702 await _make_repo(db_session)
7703 ref = "cafebabe12345678"
7704 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/key")
7705 assert response.status_code == 200
7706 body = response.text
7707 assert "Key Detection" in body
7708 assert "Relative Key" in body
7709 assert "Detection Confidence" in body
7710 assert "Alternate Key" in body
7711
7712
7713 @pytest.mark.anyio
7714 async def test_meter_analysis_page_renders(
7715 client: AsyncClient,
7716 db_session: AsyncSession,
7717 ) -> None:
7718 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/meter returns 200 HTML."""
7719 await _make_repo(db_session)
7720 ref = "abc1234567890abcdef"
7721 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/meter")
7722 assert response.status_code == 200
7723 assert "text/html" in response.headers["content-type"]
7724
7725
7726 @pytest.mark.anyio
7727 async def test_meter_analysis_page_no_auth_required(
7728 client: AsyncClient,
7729 db_session: AsyncSession,
7730 ) -> None:
7731 """Meter analysis page must be accessible without a JWT (HTML shell handles auth)."""
7732 await _make_repo(db_session)
7733 ref = "deadbeef5678"
7734 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/meter")
7735 assert response.status_code != 401
7736 assert response.status_code == 200
7737
7738
7739 @pytest.mark.anyio
7740 async def test_meter_analysis_page_contains_meter_data_labels(
7741 client: AsyncClient,
7742 db_session: AsyncSession,
7743 ) -> None:
7744 """Meter page must contain time signature, compound/simple badge, and beat strength UI."""
7745 await _make_repo(db_session)
7746 ref = "feedface5678"
7747 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/meter")
7748 assert response.status_code == 200
7749 body = response.text
7750 assert "Meter Analysis" in body
7751 assert "Time Signature" in body
7752 assert "Beat Strength Profile" in body
7753 # SSR migration (issue #578): beat strength is now rendered as inline CSS bars,
7754 # not as a JS function call. Verify the label is present and CSS bars are rendered.
7755 assert "border-radius" in body or "%" in body
7756
7757
7758 @pytest.mark.anyio
7759 async def test_chord_map_analysis_page_renders(
7760 client: AsyncClient,
7761 db_session: AsyncSession,
7762 ) -> None:
7763 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/chord-map returns 200 HTML."""
7764 await _make_repo(db_session)
7765 ref = "abc1234567890abcdef"
7766 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/chord-map")
7767 assert response.status_code == 200
7768 assert "text/html" in response.headers["content-type"]
7769
7770
7771 @pytest.mark.anyio
7772 async def test_chord_map_analysis_page_no_auth_required(
7773 client: AsyncClient,
7774 db_session: AsyncSession,
7775 ) -> None:
7776 """Chord-map analysis page must be accessible without a JWT (HTML shell handles auth)."""
7777 await _make_repo(db_session)
7778 ref = "deadbeef9999"
7779 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/chord-map")
7780 assert response.status_code != 401
7781 assert response.status_code == 200
7782
7783
7784 @pytest.mark.anyio
7785 async def test_chord_map_analysis_page_contains_chord_data_labels(
7786 client: AsyncClient,
7787 db_session: AsyncSession,
7788 ) -> None:
7789 """Chord-map page SSR: must contain progression timeline, chord table, and tension data."""
7790 await _make_repo(db_session)
7791 ref = "beefdead1234"
7792 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/chord-map")
7793 assert response.status_code == 200
7794 body = response.text
7795 assert "Chord Map" in body
7796 assert "PROGRESSION TIMELINE" in body
7797 assert "CHORD TABLE" in body
7798 assert "tension" in body.lower()
7799
7800
7801 @pytest.mark.anyio
7802 async def test_groove_analysis_page_renders(
7803 client: AsyncClient,
7804 db_session: AsyncSession,
7805 ) -> None:
7806 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/groove returns 200 HTML."""
7807 await _make_repo(db_session)
7808 ref = "abc1234567890abcdef"
7809 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/groove")
7810 assert response.status_code == 200
7811 assert "text/html" in response.headers["content-type"]
7812
7813
7814 @pytest.mark.anyio
7815 async def test_groove_analysis_page_no_auth_required(
7816 client: AsyncClient,
7817 db_session: AsyncSession,
7818 ) -> None:
7819 """Groove analysis page must be accessible without a JWT (HTML shell handles auth)."""
7820 await _make_repo(db_session)
7821 ref = "deadbeef4321"
7822 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/groove")
7823 assert response.status_code != 401
7824 assert response.status_code == 200
7825
7826
7827 @pytest.mark.anyio
7828 async def test_groove_analysis_page_contains_groove_data_labels(
7829 client: AsyncClient,
7830 db_session: AsyncSession,
7831 ) -> None:
7832 """Groove page must contain style badge, BPM, swing factor, and groove score UI."""
7833 await _make_repo(db_session)
7834 ref = "cafefeed5678"
7835 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/groove")
7836 assert response.status_code == 200
7837 body = response.text
7838 assert "Groove Analysis" in body
7839 assert "Style" in body
7840 assert "BPM" in body
7841 assert "Groove Score" in body
7842 assert "Swing Factor" in body
7843
7844
7845 @pytest.mark.anyio
7846 async def test_emotion_analysis_page_renders(
7847 client: AsyncClient,
7848 db_session: AsyncSession,
7849 ) -> None:
7850 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/emotion returns 200 HTML."""
7851 await _make_repo(db_session)
7852 ref = "abc1234567890abcdef"
7853 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/emotion")
7854 assert response.status_code == 200
7855 assert "text/html" in response.headers["content-type"]
7856
7857
7858 @pytest.mark.anyio
7859 async def test_emotion_analysis_page_no_auth_required(
7860 client: AsyncClient,
7861 db_session: AsyncSession,
7862 ) -> None:
7863 """Emotion analysis page must be accessible without a JWT (HTML shell handles auth)."""
7864 await _make_repo(db_session)
7865 ref = "deadbeef0001"
7866 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/emotion")
7867 assert response.status_code != 401
7868 assert response.status_code == 200
7869
7870
7871 @pytest.mark.anyio
7872 async def test_emotion_analysis_page_contains_emotion_data_labels(
7873 client: AsyncClient,
7874 db_session: AsyncSession,
7875 ) -> None:
7876 """Emotion page SSR: must contain SVG scatter plot and summary vector dimension bars."""
7877 await _make_repo(db_session)
7878 ref = "aabbccdd5678"
7879 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/emotion")
7880 assert response.status_code == 200
7881 body = response.text
7882 assert "Emotion Analysis" in body
7883 assert "SUMMARY VECTOR" in body
7884 assert "Valence" in body or "valence" in body
7885 assert "Tension" in body or "tension" in body
7886 assert "<circle" in body or "<svg" in body
7887
7888
7889 @pytest.mark.anyio
7890 async def test_form_analysis_page_renders(
7891 client: AsyncClient,
7892 db_session: AsyncSession,
7893 ) -> None:
7894 """GET /musehub/ui/{owner}/{repo_slug}/analysis/{ref}/form returns 200 HTML."""
7895 await _make_repo(db_session)
7896 ref = "abc1234567890abcdef"
7897 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/form")
7898 assert response.status_code == 200
7899 assert "text/html" in response.headers["content-type"]
7900
7901
7902 @pytest.mark.anyio
7903 async def test_form_analysis_page_no_auth_required(
7904 client: AsyncClient,
7905 db_session: AsyncSession,
7906 ) -> None:
7907 """Form analysis page must be accessible without a JWT (HTML shell handles auth)."""
7908 await _make_repo(db_session)
7909 ref = "deadbeef0002"
7910 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/form")
7911 assert response.status_code != 401
7912 assert response.status_code == 200
7913
7914
7915 @pytest.mark.anyio
7916 async def test_form_analysis_page_contains_form_data_labels(
7917 client: AsyncClient,
7918 db_session: AsyncSession,
7919 ) -> None:
7920 """Form page must contain form label, section timeline, and sections table."""
7921 await _make_repo(db_session)
7922 ref = "11223344abcd"
7923 response = await client.get(f"/musehub/ui/testuser/test-beats/analysis/{ref}/form")
7924 assert response.status_code == 200
7925 body = response.text
7926 assert "Form Analysis" in body
7927 assert "Form Timeline" in body or "formLabel" in body
7928 assert "Sections" in body
7929 assert "Total Beats" in body
7930
7931
7932 # ---------------------------------------------------------------------------
7933 # Issue #295 — Profile page: followers/following lists with user cards
7934 # ---------------------------------------------------------------------------
7935
7936 # test_profile_page_has_followers_following_tabs
7937 # test_profile_page_has_user_card_js
7938 # test_profile_page_has_switch_tab_js
7939 # test_followers_list_endpoint_returns_200
7940 # test_followers_list_returns_user_cards_for_known_user
7941 # test_following_list_returns_user_cards_for_known_user
7942 # test_followers_list_unknown_user_404
7943 # test_following_list_unknown_user_404
7944 # test_followers_response_includes_following_count
7945 # test_followers_list_empty_for_user_with_no_followers
7946
7947
7948 async def _make_follow(
7949 db_session: AsyncSession,
7950 follower_id: str,
7951 followee_id: str,
7952 ) -> MusehubFollow:
7953 """Seed a follow relationship and return the ORM row."""
7954 import uuid
7955 row = MusehubFollow(
7956 follow_id=str(uuid.uuid4()),
7957 follower_id=follower_id,
7958 followee_id=followee_id,
7959 )
7960 db_session.add(row)
7961 await db_session.commit()
7962 return row
7963
7964
7965 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
7966 @pytest.mark.anyio
7967 async def test_profile_page_has_followers_following_tabs(
7968 client: AsyncClient,
7969 db_session: AsyncSession,
7970 ) -> None:
7971 """Profile page must render Followers and Following tab buttons."""
7972 await _make_profile(db_session, username="tabuser")
7973 response = await client.get("/musehub/ui/users/tabuser")
7974 assert response.status_code == 200
7975 body = response.text
7976 assert "tab-btn-followers" in body
7977 assert "tab-btn-following" in body
7978
7979
7980 @pytest.mark.skip(reason="profile page now uses ui_user_profile.py inline renderer, not profile.html template")
7981 @pytest.mark.anyio
7982 async def test_profile_page_has_user_card_js(
7983 client: AsyncClient,
7984 db_session: AsyncSession,
7985 ) -> None:
7986 """Profile page must include userCardHtml and loadFollowTab JS helpers."""
7987 await _make_profile(db_session, username="cardjsuser")
7988 response = await client.get("/musehub/ui/users/cardjsuser")
7989 assert response.status_code == 200
7990 body = response.text
7991 assert "userCardHtml" in body
7992 assert "loadFollowTab" in body
7993
7994
7995 @pytest.mark.anyio
7996 async def test_profile_page_has_switch_tab_js(
7997 client: AsyncClient,
7998 db_session: AsyncSession,
7999 ) -> None:
8000 """Profile page must include switchTab() to toggle between followers and following."""
8001 await _make_profile(db_session, username="switchtabuser")
8002 response = await client.get("/musehub/ui/users/switchtabuser")
8003 assert response.status_code == 200
8004 assert "switchTab" in response.text
8005
8006
8007 @pytest.mark.anyio
8008 async def test_followers_list_endpoint_returns_200(
8009 client: AsyncClient,
8010 db_session: AsyncSession,
8011 ) -> None:
8012 """GET /api/v1/musehub/users/{username}/followers-list returns 200 for known user."""
8013 await _make_profile(db_session, username="followerlistuser")
8014 response = await client.get("/api/v1/musehub/users/followerlistuser/followers-list")
8015 assert response.status_code == 200
8016 assert isinstance(response.json(), list)
8017
8018
8019 @pytest.mark.anyio
8020 async def test_followers_list_returns_user_cards_for_known_user(
8021 client: AsyncClient,
8022 db_session: AsyncSession,
8023 ) -> None:
8024 """followers-list returns UserCard objects when followers exist."""
8025 import uuid
8026
8027 target = MusehubProfile(
8028 user_id="target-user-fl-01",
8029 username="flctarget",
8030 bio="I am the target",
8031 avatar_url=None,
8032 pinned_repo_ids=[],
8033 )
8034 follower = MusehubProfile(
8035 user_id="follower-user-fl-01",
8036 username="flcfollower",
8037 bio="I am a follower",
8038 avatar_url=None,
8039 pinned_repo_ids=[],
8040 )
8041 db_session.add(target)
8042 db_session.add(follower)
8043 await db_session.flush()
8044 # Seed a follow row using user_ids (same convention as the seed script)
8045 await _make_follow(db_session, follower_id="follower-user-fl-01", followee_id="target-user-fl-01")
8046
8047 response = await client.get("/api/v1/musehub/users/flctarget/followers-list")
8048 assert response.status_code == 200
8049 cards = response.json()
8050 assert len(cards) >= 1
8051 usernames = [c["username"] for c in cards]
8052 assert "flcfollower" in usernames
8053
8054
8055 @pytest.mark.anyio
8056 async def test_following_list_returns_user_cards_for_known_user(
8057 client: AsyncClient,
8058 db_session: AsyncSession,
8059 ) -> None:
8060 """following-list returns UserCard objects for users that the target follows."""
8061 actor = MusehubProfile(
8062 user_id="actor-user-fl-02",
8063 username="flcactor",
8064 bio="I follow people",
8065 avatar_url=None,
8066 pinned_repo_ids=[],
8067 )
8068 followee = MusehubProfile(
8069 user_id="followee-user-fl-02",
8070 username="flcfollowee",
8071 bio="I am followed",
8072 avatar_url=None,
8073 pinned_repo_ids=[],
8074 )
8075 db_session.add(actor)
8076 db_session.add(followee)
8077 await db_session.flush()
8078 await _make_follow(db_session, follower_id="actor-user-fl-02", followee_id="followee-user-fl-02")
8079
8080 response = await client.get("/api/v1/musehub/users/flcactor/following-list")
8081 assert response.status_code == 200
8082 cards = response.json()
8083 assert len(cards) >= 1
8084 usernames = [c["username"] for c in cards]
8085 assert "flcfollowee" in usernames
8086
8087
8088 @pytest.mark.anyio
8089 async def test_followers_list_unknown_user_404(
8090 client: AsyncClient,
8091 db_session: AsyncSession,
8092 ) -> None:
8093 """followers-list returns 404 when the target username does not exist."""
8094 response = await client.get("/api/v1/musehub/users/nonexistent-ghost-user/followers-list")
8095 assert response.status_code == 404
8096
8097
8098 @pytest.mark.anyio
8099 async def test_following_list_unknown_user_404(
8100 client: AsyncClient,
8101 db_session: AsyncSession,
8102 ) -> None:
8103 """following-list returns 404 when the target username does not exist."""
8104 response = await client.get("/api/v1/musehub/users/nonexistent-ghost-user/following-list")
8105 assert response.status_code == 404
8106
8107
8108 @pytest.mark.anyio
8109 async def test_followers_response_includes_following_count(
8110 client: AsyncClient,
8111 db_session: AsyncSession,
8112 ) -> None:
8113 """GET /users/{username}/followers now includes following_count in response."""
8114 await _make_profile(db_session, username="followcountuser")
8115 response = await client.get("/api/v1/musehub/users/followcountuser/followers")
8116 assert response.status_code == 200
8117 data = response.json()
8118 assert "followerCount" in data or "follower_count" in data
8119 assert "followingCount" in data or "following_count" in data
8120
8121
8122 @pytest.mark.anyio
8123 async def test_followers_list_empty_for_user_with_no_followers(
8124 client: AsyncClient,
8125 db_session: AsyncSession,
8126 ) -> None:
8127 """followers-list returns an empty list when no one follows the user."""
8128 await _make_profile(db_session, username="lonelyuser295")
8129 response = await client.get("/api/v1/musehub/users/lonelyuser295/followers-list")
8130 assert response.status_code == 200
8131 assert response.json() == []
8132
8133
8134 # ---------------------------------------------------------------------------
8135 # Issue #450 — Enhanced commit detail: inline audio player, muse_tags panel,
8136 # reactions, comment thread, cross-references
8137 # ---------------------------------------------------------------------------
8138
8139
8140 @pytest.mark.anyio
8141 async def test_commit_page_has_inline_audio_player_section(
8142 client: AsyncClient,
8143 db_session: AsyncSession,
8144 ) -> None:
8145 """Commit detail page (SSR, issue #583) renders WaveSurfer shell when snapshot_id is set.
8146
8147 Post-SSR migration: the audio player shell (commit-waveform + WaveSurfer script)
8148 is rendered only when the commit has a snapshot_id. Non-existent commits → 404.
8149 """
8150 from datetime import datetime, timezone
8151 from musehub.db.musehub_models import MusehubCommit
8152
8153 repo = MusehubRepo(
8154 name="audio-player-test",
8155 owner="audiouser",
8156 slug="audio-player-test",
8157 visibility="public",
8158 owner_user_id="audio-uid",
8159 )
8160 db_session.add(repo)
8161 await db_session.commit()
8162 await db_session.refresh(repo)
8163
8164 snap_id = "sha256:deadbeefcafe"
8165 commit_id = "c0ffee0000111122223333444455556666c0ffee"
8166 commit = MusehubCommit(
8167 commit_id=commit_id,
8168 repo_id=str(repo.repo_id),
8169 branch="main",
8170 parent_ids=[],
8171 message="Add audio snapshot",
8172 author="audiouser",
8173 timestamp=datetime.now(tz=timezone.utc),
8174 snapshot_id=snap_id,
8175 )
8176 db_session.add(commit)
8177 await db_session.commit()
8178
8179 response = await client.get(f"/musehub/ui/audiouser/audio-player-test/commits/{commit_id}")
8180 assert response.status_code == 200
8181 body = response.text
8182 # SSR audio shell: waveform div with data-url set from snapshot_id
8183 assert "commit-waveform" in body
8184 assert snap_id in body
8185 # WaveSurfer vendor script still loaded
8186 assert "wavesurfer" in body.lower()
8187 # Listen link rendered
8188 assert "Listen" in body
8189
8190
8191 @pytest.mark.anyio
8192 async def test_commit_page_inline_player_has_track_selector_js(
8193 client: AsyncClient,
8194 db_session: AsyncSession,
8195 ) -> None:
8196 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8197
8198 Track selector JS was part of the pre-SSR commit.html. The new commit_detail.html
8199 renders a simplified WaveSurfer shell from the commit's snapshot_id.
8200 Non-existent commits return 404 rather than an empty JS shell.
8201 """
8202 repo = MusehubRepo(
8203 name="track-sel-test",
8204 owner="trackuser",
8205 slug="track-sel-test",
8206 visibility="public",
8207 owner_user_id="track-uid",
8208 )
8209 db_session.add(repo)
8210 await db_session.commit()
8211 await db_session.refresh(repo)
8212
8213 commit_id = "aaaa1111bbbb2222cccc3333dddd4444eeee5555"
8214 response = await client.get(f"/musehub/ui/trackuser/track-sel-test/commits/{commit_id}")
8215 assert response.status_code == 404
8216
8217
8218 @pytest.mark.anyio
8219 async def test_commit_page_has_muse_tags_panel(
8220 client: AsyncClient,
8221 db_session: AsyncSession,
8222 ) -> None:
8223 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8224
8225 The muse-tags-panel was a JS-only construct in the pre-SSR commit.html.
8226 The new commit_detail.html renders metadata server-side; the muse-tags panel
8227 is not present. Non-existent commits return 404.
8228 """
8229 repo = MusehubRepo(
8230 name="tags-panel-test",
8231 owner="tagsuser",
8232 slug="tags-panel-test",
8233 visibility="public",
8234 owner_user_id="tags-uid",
8235 )
8236 db_session.add(repo)
8237 await db_session.commit()
8238 await db_session.refresh(repo)
8239
8240 commit_id = "1234567890abcdef1234567890abcdef12345678"
8241 response = await client.get(f"/musehub/ui/tagsuser/tags-panel-test/commits/{commit_id}")
8242 assert response.status_code == 404
8243
8244
8245 @pytest.mark.anyio
8246 async def test_commit_page_muse_tags_pill_colours_defined(
8247 client: AsyncClient,
8248 db_session: AsyncSession,
8249 ) -> None:
8250 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8251
8252 Muse-pill CSS classes were part of the pre-SSR commit.html analysis panel.
8253 The new commit_detail.html does not include muse-pill classes.
8254 Non-existent commits return 404.
8255 """
8256 repo = MusehubRepo(
8257 name="pill-colour-test",
8258 owner="pilluser",
8259 slug="pill-colour-test",
8260 visibility="public",
8261 owner_user_id="pill-uid",
8262 )
8263 db_session.add(repo)
8264 await db_session.commit()
8265 await db_session.refresh(repo)
8266
8267 commit_id = "abcd1234ef567890abcd1234ef567890abcd1234"
8268 response = await client.get(f"/musehub/ui/pilluser/pill-colour-test/commits/{commit_id}")
8269 assert response.status_code == 404
8270
8271
8272 @pytest.mark.anyio
8273 async def test_commit_page_has_cross_references_section(
8274 client: AsyncClient,
8275 db_session: AsyncSession,
8276 ) -> None:
8277 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8278
8279 The cross-references panel (xrefs-body, loadCrossReferences) was a JS-only
8280 construct in the pre-SSR commit.html. The new commit_detail.html does not
8281 include this panel. Non-existent commits return 404.
8282 """
8283 repo = MusehubRepo(
8284 name="xrefs-test",
8285 owner="xrefsuser",
8286 slug="xrefs-test",
8287 visibility="public",
8288 owner_user_id="xrefs-uid",
8289 )
8290 db_session.add(repo)
8291 await db_session.commit()
8292 await db_session.refresh(repo)
8293
8294 commit_id = "face000011112222333344445555666677778888"
8295 response = await client.get(f"/musehub/ui/xrefsuser/xrefs-test/commits/{commit_id}")
8296 assert response.status_code == 404
8297
8298
8299 @pytest.mark.anyio
8300 async def test_commit_page_context_passes_listen_and_embed_urls(
8301 client: AsyncClient,
8302 db_session: AsyncSession,
8303 ) -> None:
8304 """commit_page() (SSR, issue #583) injects listenUrl and embedUrl into the JS page-data block.
8305
8306 The SSR template still exposes these URLs server-side for the JS and for
8307 navigation links. Requires the commit to exist in the DB.
8308 """
8309 from datetime import datetime, timezone
8310 from musehub.db.musehub_models import MusehubCommit
8311
8312 repo = MusehubRepo(
8313 name="url-context-test",
8314 owner="urluser",
8315 slug="url-context-test",
8316 visibility="public",
8317 owner_user_id="url-uid",
8318 )
8319 db_session.add(repo)
8320 await db_session.commit()
8321 await db_session.refresh(repo)
8322
8323 commit_id = "dead0000beef1111dead0000beef1111dead0000"
8324 commit = MusehubCommit(
8325 commit_id=commit_id,
8326 repo_id=str(repo.repo_id),
8327 branch="main",
8328 parent_ids=[],
8329 message="URL context test commit",
8330 author="urluser",
8331 timestamp=datetime.now(tz=timezone.utc),
8332 )
8333 db_session.add(commit)
8334 await db_session.commit()
8335
8336 response = await client.get(f"/musehub/ui/urluser/url-context-test/commits/{commit_id}")
8337 assert response.status_code == 200
8338 body = response.text
8339 assert "listenUrl" in body
8340 assert "embedUrl" in body
8341 assert f"/listen/{commit_id}" in body
8342 assert f"/embed/{commit_id}" in body
8343
8344
8345 # ---------------------------------------------------------------------------
8346 # Issue #442 — Repo landing page enrichment panels
8347 # Explore page — filter sidebar + inline audio preview
8348 # ---------------------------------------------------------------------------
8349
8350
8351 @pytest.mark.anyio
8352 async def test_repo_home_contributors_panel_js(
8353 client: AsyncClient,
8354 db_session: AsyncSession,
8355 ) -> None:
8356 """Repo home page includes the contributors panel JS.
8357
8358 The panel calls the /credits endpoint and renders top-10 contributor
8359 avatars linked to user profile pages with a commit count badge.
8360 """
8361 repo = MusehubRepo(
8362 name="contrib-panel-test",
8363 owner="contribowner",
8364 slug="contrib-panel-test",
8365 visibility="public",
8366 owner_user_id="contrib-uid",
8367 )
8368 db_session.add(repo)
8369 await db_session.commit()
8370
8371 response = await client.get("/musehub/ui/contribowner/contrib-panel-test")
8372 assert response.status_code == 200
8373 body = response.text
8374 assert "loadContributors" in body
8375 assert "contributors-panel" in body
8376 assert "contrib-avatar" in body
8377 assert "contrib-badge" in body
8378 assert "credits" in body
8379
8380
8381 @pytest.mark.anyio
8382 async def test_repo_home_activity_heatmap_js(
8383 client: AsyncClient,
8384 db_session: AsyncSession,
8385 ) -> None:
8386 """Repo home page includes the activity heatmap JS.
8387
8388 The heatmap renders a 52-week GitHub-style grid from commit timestamps
8389 with tooltip-on-hover showing date and commit count.
8390 """
8391 repo = MusehubRepo(
8392 name="heatmap-panel-test",
8393 owner="heatmapowner",
8394 slug="heatmap-panel-test",
8395 visibility="public",
8396 owner_user_id="heatmap-uid",
8397 )
8398 db_session.add(repo)
8399 await db_session.commit()
8400
8401 response = await client.get("/musehub/ui/heatmapowner/heatmap-panel-test")
8402 assert response.status_code == 200
8403 body = response.text
8404 assert "loadActivityHeatmap" in body
8405 assert "activity-heatmap" in body
8406 assert "hm-cell" in body
8407 assert "hm-grid" in body
8408
8409
8410 @pytest.mark.anyio
8411 async def test_repo_home_instrument_bar_js(
8412 client: AsyncClient,
8413 db_session: AsyncSession,
8414 ) -> None:
8415 """Repo home page includes the instrument distribution bar JS.
8416
8417 The bar shows stacked segments labelled with instrument names and
8418 percentages, derived from MIDI/audio object paths in the latest commit.
8419 """
8420 repo = MusehubRepo(
8421 name="instrbar-panel-test",
8422 owner="instrbarowner",
8423 slug="instrbar-panel-test",
8424 visibility="public",
8425 owner_user_id="instrbar-uid",
8426 )
8427 db_session.add(repo)
8428 await db_session.commit()
8429
8430 response = await client.get("/musehub/ui/instrbarowner/instrbar-panel-test")
8431 assert response.status_code == 200
8432 body = response.text
8433 assert "loadInstrumentBar" in body
8434 assert "instrument-bar" in body
8435 assert "instr-bar" in body
8436 assert "instr-seg" in body
8437
8438
8439 @pytest.mark.anyio
8440 async def test_repo_home_clone_widget_renders(
8441 client: AsyncClient,
8442 db_session: AsyncSession,
8443 ) -> None:
8444 """Repo home page includes the clone widget with musehub://, SSH, and HTTPS URLs.
8445
8446 The widget shows three copy-to-clipboard inputs and a Download ZIP button.
8447 Clone URLs are injected server-side by repo_page() so the JS template
8448 never has to reconstruct them from owner/slug.
8449 """
8450 repo = MusehubRepo(
8451 name="clone-widget-test",
8452 owner="cloneowner",
8453 slug="clone-widget-test",
8454 visibility="public",
8455 owner_user_id="clone-uid",
8456 )
8457 db_session.add(repo)
8458 await db_session.commit()
8459
8460 response = await client.get("/musehub/ui/cloneowner/clone-widget-test")
8461 assert response.status_code == 200
8462 body = response.text
8463
8464 # Clone URL constants injected by repo_page()
8465 assert "CLONE_MUSEHUB" in body
8466 assert "musehub://cloneowner/clone-widget-test" in body
8467 assert "ssh://git@musehub.app/cloneowner/clone-widget-test.git" in body
8468 assert "https://musehub.app/cloneowner/clone-widget-test.git" in body
8469
8470 # DOM elements rendered by renderCloneWidget()
8471 assert "clone-widget" in body
8472 assert "renderCloneWidget" in body
8473 assert "clone-input" in body
8474 assert "copyClone" in body
8475 assert "Download ZIP" in body
8476 async def test_explore_page_returns_200(
8477 client: AsyncClient,
8478 ) -> None:
8479 """GET /musehub/ui/explore returns 200 without authentication."""
8480 response = await client.get("/musehub/ui/explore")
8481 assert response.status_code == 200
8482
8483
8484 @pytest.mark.anyio
8485 async def test_explore_page_has_filter_sidebar(
8486 client: AsyncClient,
8487 ) -> None:
8488 """Explore page renders a filter sidebar with sort, license, and clear-filters sections."""
8489 response = await client.get("/musehub/ui/explore")
8490 assert response.status_code == 200
8491 body = response.text
8492 assert "explore-sidebar" in body
8493 assert "Clear filters" in body
8494 assert "Sort by" in body
8495 assert "License" in body
8496
8497
8498 @pytest.mark.anyio
8499 async def test_explore_page_has_sort_options(
8500 client: AsyncClient,
8501 ) -> None:
8502 """Explore page sidebar includes all four sort radio options."""
8503 response = await client.get("/musehub/ui/explore")
8504 assert response.status_code == 200
8505 body = response.text
8506 assert "Most starred" in body
8507 assert "Recently updated" in body
8508 assert "Most forked" in body
8509 assert "Trending" in body
8510
8511
8512 @pytest.mark.anyio
8513 async def test_explore_page_has_license_options(
8514 client: AsyncClient,
8515 ) -> None:
8516 """Explore page sidebar includes the expected license filter options."""
8517 response = await client.get("/musehub/ui/explore")
8518 assert response.status_code == 200
8519 body = response.text
8520 assert "CC0" in body
8521 assert "CC BY" in body
8522 assert "CC BY-SA" in body
8523 assert "CC BY-NC" in body
8524 assert "All Rights Reserved" in body
8525
8526
8527 @pytest.mark.anyio
8528 async def test_explore_page_has_repo_grid(
8529 client: AsyncClient,
8530 ) -> None:
8531 """Explore page includes the repo grid and JS discover API loader."""
8532 response = await client.get("/musehub/ui/explore")
8533 assert response.status_code == 200
8534 body = response.text
8535 assert "repo-grid" in body
8536 assert "loadExplore" in body
8537 assert "DISCOVER_API" in body
8538
8539
8540 @pytest.mark.anyio
8541 async def test_explore_page_has_audio_preview_js(
8542 client: AsyncClient,
8543 ) -> None:
8544 """Explore page includes the inline hover audio preview JavaScript (500ms delay)."""
8545 response = await client.get("/musehub/ui/explore")
8546 assert response.status_code == 200
8547 body = response.text
8548 assert "card-audio-preview" in body
8549 assert "toggleCardPreview" in body
8550 assert "onCardMouseEnter" in body
8551 assert "500" in body
8552
8553
8554 @pytest.mark.anyio
8555 async def test_explore_page_default_sort_stars(
8556 client: AsyncClient,
8557 ) -> None:
8558 """Explore page defaults to 'stars' sort when no sort param given."""
8559 response = await client.get("/musehub/ui/explore")
8560 assert response.status_code == 200
8561 body = response.text
8562 # 'stars' radio should be pre-checked (default sort)
8563 assert 'value="stars"' in body
8564 assert 'checked' in body
8565
8566
8567 @pytest.mark.anyio
8568 async def test_explore_page_sort_param_honoured(
8569 client: AsyncClient,
8570 ) -> None:
8571 """Explore page honours the ?sort= query param for pre-selecting a sort option."""
8572 response = await client.get("/musehub/ui/explore?sort=updated")
8573 assert response.status_code == 200
8574 body = response.text
8575 assert 'value="updated"' in body
8576
8577
8578 @pytest.mark.anyio
8579 async def test_explore_page_no_auth_required(
8580 client: AsyncClient,
8581 ) -> None:
8582 """Explore page is publicly accessible — no JWT required (zero-friction discovery)."""
8583 response = await client.get("/musehub/ui/explore")
8584 assert response.status_code == 200
8585 assert response.status_code != 401
8586 assert response.status_code != 403
8587
8588
8589 @pytest.mark.anyio
8590 async def test_explore_page_chip_toggle_js(
8591 client: AsyncClient,
8592 ) -> None:
8593 """Explore page includes toggleChip JS for progressive chip filter enhancement."""
8594 response = await client.get("/musehub/ui/explore")
8595 assert response.status_code == 200
8596 body = response.text
8597 assert "toggleChip" in body
8598 assert "filter-chip" in body
8599
8600
8601 @pytest.mark.anyio
8602 async def test_explore_page_get_params_preserved(
8603 client: AsyncClient,
8604 ) -> None:
8605 """Explore page accepts lang, license, topic, sort GET params without error."""
8606 response = await client.get(
8607 "/musehub/ui/explore?lang=piano&license=CC0&topic=jazz&sort=stars"
8608 )
8609 assert response.status_code == 200