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