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