gabriel / musehub public
test_musehub_ui.py python
8675 lines 287.9 KB
cde19258 chore: merge dev into main (#48) Gabriel Cardona <cgcardona@gmail.com> 1d 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 an Insights link for the 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 "/insights/" 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} and /insights/{ref} both return 200.
5121
5122 /view/* is an alias for /insights — same handler, same page, no redirect.
5123 """
5124 await _seed_listen_fixtures(db_session)
5125 ref = "main"
5126 # /view URL is an alias — returns 200 directly (no redirect)
5127 response = await client.get(f"/testuser/listen-test/view/{ref}")
5128 assert response.status_code == 200
5129 assert "text/html" in response.headers["content-type"]
5130 assert "MuseHub" in response.text
5131 # Canonical /insights URL also returns 200
5132 response2 = await client.get(f"/testuser/listen-test/insights/{ref}")
5133 assert response2.status_code == 200
5134 assert "MuseHub" in response2.text
5135
5136
5137 @pytest.mark.anyio
5138 async def test_listen_page_track_listing(
5139 client: AsyncClient,
5140 db_session: AsyncSession,
5141 ) -> None:
5142 """Insights page (aliases /view) renders the domain viewer container server-side."""
5143 await _seed_listen_fixtures(db_session)
5144 ref = "main"
5145 response = await client.get(f"/testuser/listen-test/view/{ref}")
5146 assert response.status_code == 200
5147 body = response.text
5148 # Insights page rendered SSR
5149 assert "ins-page" in body
5150
5151
5152 @pytest.mark.anyio
5153 async def test_listen_page_no_renders_fallback(
5154 client: AsyncClient,
5155 db_session: AsyncSession,
5156 ) -> None:
5157 """View page renders successfully for a repo with no committed objects."""
5158 # Repo with no objects at all
5159 repo = MusehubRepo(
5160 name="silent-repo",
5161 owner="testuser",
5162 slug="silent-repo",
5163 visibility="public",
5164 owner_user_id="test-owner",
5165 )
5166 db_session.add(repo)
5167 await db_session.commit()
5168
5169 response = await client.get("/testuser/silent-repo/view/main")
5170 assert response.status_code == 200
5171 body = response.text
5172 # Insights page (aliases /view) renders successfully
5173 assert "ins-page" in body or "MuseHub" in body
5174
5175
5176 @pytest.mark.anyio
5177 async def test_listen_page_json_response(
5178 client: AsyncClient,
5179 db_session: AsyncSession,
5180 ) -> None:
5181 """GET /{owner}/{repo}/view/{ref} returns HTML (insights page, /view is an alias)."""
5182 await _seed_listen_fixtures(db_session)
5183 ref = "main"
5184 response = await client.get(f"/testuser/listen-test/view/{ref}")
5185 assert response.status_code == 200
5186 assert "text/html" in response.headers["content-type"]
5187 assert "MuseHub" in response.text
5188
5189
5190 # ---------------------------------------------------------------------------
5191 # Issue #366 — musehub_listen service function (direct unit tests)
5192 # ---------------------------------------------------------------------------
5193
5194
5195 @pytest.mark.anyio
5196 async def test_build_track_listing_returns_full_mix_and_tracks(
5197 db_session: AsyncSession,
5198 ) -> None:
5199 """build_track_listing() returns a populated TrackListingResponse with mix + stems."""
5200 from musehub.services.musehub_listen import build_track_listing
5201
5202 repo = MusehubRepo(
5203 name="svc-listen-test",
5204 owner="svcuser",
5205 slug="svc-listen-test",
5206 visibility="public",
5207 owner_user_id="svc-owner",
5208 )
5209 db_session.add(repo)
5210 await db_session.commit()
5211 await db_session.refresh(repo)
5212 repo_id = str(repo.repo_id)
5213
5214 for path, size in [
5215 ("mix/full_mix.mp3", 204800),
5216 ("tracks/bass.mp3", 51200),
5217 ("tracks/keys.mp3", 61440),
5218 ("tracks/bass.webp", 8192),
5219 ]:
5220 obj = MusehubObject(
5221 object_id=f"sha256:svc_{path.replace('/', '_')}",
5222 repo_id=repo_id,
5223 path=path,
5224 size_bytes=size,
5225 disk_path=f"/tmp/svc_{path.replace('/', '_')}",
5226 )
5227 db_session.add(obj)
5228 await db_session.commit()
5229
5230 result = await build_track_listing(db_session, repo_id, "main")
5231
5232 assert result.has_renders is True
5233 assert result.repo_id == repo_id
5234 assert result.ref == "main"
5235 # full-mix URL points to the mix file (contains "mix" keyword)
5236 assert result.full_mix_url is not None
5237 assert "full_mix" in result.full_mix_url or "mix" in result.full_mix_url
5238 # Two audio tracks (bass.mp3 + keys.mp3); bass.webp is not audio
5239 assert len(result.tracks) == 3 # mix/full_mix.mp3, tracks/bass.mp3, tracks/keys.mp3
5240 track_paths = {t.path for t in result.tracks}
5241 assert "tracks/bass.mp3" in track_paths
5242 assert "tracks/keys.mp3" in track_paths
5243 # Piano-roll URL attached to bass.mp3 (matching bass.webp exists)
5244 bass_track = next(t for t in result.tracks if t.path == "tracks/bass.mp3")
5245 assert bass_track.piano_roll_url is not None
5246
5247
5248 @pytest.mark.anyio
5249 async def test_build_track_listing_no_audio_returns_empty(
5250 db_session: AsyncSession,
5251 ) -> None:
5252 """build_track_listing() returns has_renders=False when no audio objects exist."""
5253 from musehub.services.musehub_listen import build_track_listing
5254
5255 repo = MusehubRepo(
5256 name="svc-silent-test",
5257 owner="svcuser",
5258 slug="svc-silent-test",
5259 visibility="public",
5260 owner_user_id="svc-owner",
5261 )
5262 db_session.add(repo)
5263 await db_session.commit()
5264 await db_session.refresh(repo)
5265 repo_id = str(repo.repo_id)
5266
5267 # Only a non-audio object
5268 obj = MusehubObject(
5269 object_id="sha256:svc_midi",
5270 repo_id=repo_id,
5271 path="tracks/bass.mid",
5272 size_bytes=1024,
5273 disk_path="/tmp/svc_bass.mid",
5274 )
5275 db_session.add(obj)
5276 await db_session.commit()
5277
5278 result = await build_track_listing(db_session, repo_id, "dev")
5279
5280 assert result.has_renders is False
5281 assert result.full_mix_url is None
5282 assert result.tracks == []
5283
5284
5285 @pytest.mark.anyio
5286 async def test_build_track_listing_no_mix_keyword_uses_first_alphabetically(
5287 db_session: AsyncSession,
5288 ) -> None:
5289 """When no file matches _FULL_MIX_KEYWORDS, the first audio file (by path) is used."""
5290 from musehub.services.musehub_listen import build_track_listing
5291
5292 repo = MusehubRepo(
5293 name="svc-nomix-test",
5294 owner="svcuser",
5295 slug="svc-nomix-test",
5296 visibility="public",
5297 owner_user_id="svc-owner",
5298 )
5299 db_session.add(repo)
5300 await db_session.commit()
5301 await db_session.refresh(repo)
5302 repo_id = str(repo.repo_id)
5303
5304 for path, size in [
5305 ("tracks/bass.mp3", 51200),
5306 ("tracks/drums.mp3", 61440),
5307 ]:
5308 obj = MusehubObject(
5309 object_id=f"sha256:svc_nomix_{path.replace('/', '_')}",
5310 repo_id=repo_id,
5311 path=path,
5312 size_bytes=size,
5313 disk_path=f"/tmp/svc_nomix_{path.replace('/', '_')}",
5314 )
5315 db_session.add(obj)
5316 await db_session.commit()
5317
5318 result = await build_track_listing(db_session, repo_id, "main")
5319
5320 assert result.has_renders is True
5321 # 'tracks/bass.mp3' sorts before 'tracks/drums.mp3'
5322 assert result.full_mix_url is not None
5323 assert "bass" in result.full_mix_url
5324
5325
5326 # ---------------------------------------------------------------------------
5327 # Issue #206 — Commit list page
5328 # ---------------------------------------------------------------------------
5329
5330 _COMMIT_LIST_OWNER = "commitowner"
5331 _COMMIT_LIST_SLUG = "commit-list-repo"
5332 _SHA_MAIN_1 = "aa001122334455667788990011223344556677889900"
5333 _SHA_MAIN_2 = "bb001122334455667788990011223344556677889900"
5334 _SHA_MAIN_MERGE = "cc001122334455667788990011223344556677889900"
5335 _SHA_FEAT = "ff001122334455667788990011223344556677889900"
5336
5337
5338 async def _seed_commit_list_repo(
5339 db_session: AsyncSession,
5340 ) -> str:
5341 """Seed a repo with 2 commits on main, 1 merge commit, and 1 on feat branch."""
5342 repo = MusehubRepo(
5343 name=_COMMIT_LIST_SLUG,
5344 owner=_COMMIT_LIST_OWNER,
5345 slug=_COMMIT_LIST_SLUG,
5346 visibility="public",
5347 owner_user_id="commit-owner-uid",
5348 )
5349 db_session.add(repo)
5350 await db_session.flush()
5351 repo_id = str(repo.repo_id)
5352
5353 branch_main = MusehubBranch(repo_id=repo_id, name="main", head_commit_id=_SHA_MAIN_MERGE)
5354 branch_feat = MusehubBranch(repo_id=repo_id, name="feat/drums", head_commit_id=_SHA_FEAT)
5355 db_session.add_all([branch_main, branch_feat])
5356
5357 now = datetime.now(UTC)
5358 commits = [
5359 MusehubCommit(
5360 commit_id=_SHA_MAIN_1,
5361 repo_id=repo_id,
5362 branch="main",
5363 parent_ids=[],
5364 message="feat(bass): root commit with walking bass line",
5365 author="composer@muse.app",
5366 timestamp=now - timedelta(hours=4),
5367 ),
5368 MusehubCommit(
5369 commit_id=_SHA_MAIN_2,
5370 repo_id=repo_id,
5371 branch="main",
5372 parent_ids=[_SHA_MAIN_1],
5373 message="feat(keys): add rhodes chord voicings in verse",
5374 author="composer@muse.app",
5375 timestamp=now - timedelta(hours=2),
5376 ),
5377 MusehubCommit(
5378 commit_id=_SHA_MAIN_MERGE,
5379 repo_id=repo_id,
5380 branch="main",
5381 parent_ids=[_SHA_MAIN_2, _SHA_FEAT],
5382 message="merge(feat/drums): integrate drum pattern into main",
5383 author="composer@muse.app",
5384 timestamp=now - timedelta(hours=1),
5385 ),
5386 MusehubCommit(
5387 commit_id=_SHA_FEAT,
5388 repo_id=repo_id,
5389 branch="feat/drums",
5390 parent_ids=[_SHA_MAIN_1],
5391 message="feat(drums): add kick and snare pattern at 120 BPM",
5392 author="drummer@muse.app",
5393 timestamp=now - timedelta(hours=3),
5394 ),
5395 ]
5396 db_session.add_all(commits)
5397 await db_session.commit()
5398 return repo_id
5399
5400
5401 @pytest.mark.anyio
5402 async def test_commits_list_page_returns_200(
5403 client: AsyncClient,
5404 db_session: AsyncSession,
5405 ) -> None:
5406 """GET /{owner}/{repo}/commits returns 200 HTML."""
5407 await _seed_commit_list_repo(db_session)
5408 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5409 assert resp.status_code == 200
5410 assert "text/html" in resp.headers["content-type"]
5411 assert "MuseHub" in resp.text
5412
5413
5414 @pytest.mark.anyio
5415 async def test_commits_list_page_shows_commit_sha(
5416 client: AsyncClient,
5417 db_session: AsyncSession,
5418 ) -> None:
5419 """Commit SHA (first 8 chars) appears in the rendered HTML."""
5420 await _seed_commit_list_repo(db_session)
5421 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5422 assert resp.status_code == 200
5423 # All 4 commits should appear (per_page=30 default, total=4)
5424 assert _SHA_MAIN_1[:8] in resp.text
5425 assert _SHA_MAIN_2[:8] in resp.text
5426 assert _SHA_MAIN_MERGE[:8] in resp.text
5427 assert _SHA_FEAT[:8] in resp.text
5428
5429
5430 @pytest.mark.anyio
5431 async def test_commits_list_page_shows_commit_message(
5432 client: AsyncClient,
5433 db_session: AsyncSession,
5434 ) -> None:
5435 """Commit messages appear truncated in commit rows."""
5436 await _seed_commit_list_repo(db_session)
5437 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5438 assert resp.status_code == 200
5439 assert "walking bass line" in resp.text
5440 assert "rhodes chord voicings" in resp.text
5441
5442
5443 @pytest.mark.anyio
5444 async def test_commits_list_page_dag_indicator(
5445 client: AsyncClient,
5446 db_session: AsyncSession,
5447 ) -> None:
5448 """DAG node CSS class is present in the HTML for every commit row."""
5449 await _seed_commit_list_repo(db_session)
5450 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5451 assert resp.status_code == 200
5452 assert "dag-node" in resp.text
5453 assert "commit-list-row" in resp.text
5454
5455
5456 @pytest.mark.anyio
5457 async def test_commits_list_page_merge_indicator(
5458 client: AsyncClient,
5459 db_session: AsyncSession,
5460 ) -> None:
5461 """Merge commits display the merge indicator and dag-node-merge class."""
5462 await _seed_commit_list_repo(db_session)
5463 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5464 assert resp.status_code == 200
5465 assert "dag-node-merge" in resp.text
5466 assert "merge" in resp.text.lower()
5467
5468
5469 @pytest.mark.anyio
5470 async def test_commits_list_page_branch_selector(
5471 client: AsyncClient,
5472 db_session: AsyncSession,
5473 ) -> None:
5474 """Branch <select> dropdown is present when the repo has branches."""
5475 await _seed_commit_list_repo(db_session)
5476 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5477 assert resp.status_code == 200
5478 # Select element with branch options
5479 assert "branch-sel" in resp.text
5480 assert "main" in resp.text
5481 assert "feat/drums" in resp.text
5482
5483
5484 @pytest.mark.anyio
5485 async def test_commits_list_page_graph_link(
5486 client: AsyncClient,
5487 db_session: AsyncSession,
5488 ) -> None:
5489 """Link to the DAG graph page is present."""
5490 await _seed_commit_list_repo(db_session)
5491 resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits")
5492 assert resp.status_code == 200
5493 assert "/graph" in resp.text
5494
5495
5496 @pytest.mark.anyio
5497 async def test_commits_list_page_pagination_links(
5498 client: AsyncClient,
5499 db_session: AsyncSession,
5500 ) -> None:
5501 """Pagination nav links appear when total exceeds per_page."""
5502 await _seed_commit_list_repo(db_session)
5503 # Request per_page=2 so 4 commits produce 2 pages
5504 resp = await client.get(
5505 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=1"
5506 )
5507 assert resp.status_code == 200
5508 body = resp.text
5509 # "Older" link should be active (page 1 has no "Newer")
5510 assert "Older" in body
5511 # "Newer" should be disabled on page 1
5512 assert "Newer" in body
5513 assert "page=2" in body
5514
5515
5516 @pytest.mark.anyio
5517 async def test_commits_list_page_pagination_page2(
5518 client: AsyncClient,
5519 db_session: AsyncSession,
5520 ) -> None:
5521 """Page 2 renders with Newer navigation active."""
5522 await _seed_commit_list_repo(db_session)
5523 resp = await client.get(
5524 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=2"
5525 )
5526 assert resp.status_code == 200
5527 body = resp.text
5528 assert "page=1" in body # "Newer" link points back to page 1
5529
5530
5531 @pytest.mark.anyio
5532 async def test_commits_list_page_branch_filter_html(
5533 client: AsyncClient,
5534 db_session: AsyncSession,
5535 ) -> None:
5536 """?branch=main returns only main-branch commits in HTML."""
5537 await _seed_commit_list_repo(db_session)
5538 resp = await client.get(
5539 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?branch=main"
5540 )
5541 assert resp.status_code == 200
5542 body = resp.text
5543 # main commits appear
5544 assert _SHA_MAIN_1[:8] in body
5545 assert _SHA_MAIN_2[:8] in body
5546 assert _SHA_MAIN_MERGE[:8] in body
5547 # feat/drums commit should NOT appear when filtered to main
5548 assert _SHA_FEAT[:8] not in body
5549
5550
5551 @pytest.mark.anyio
5552 async def test_commits_list_page_json_content_negotiation(
5553 client: AsyncClient,
5554 db_session: AsyncSession,
5555 ) -> None:
5556 """?format=json returns CommitListResponse JSON with commits and total."""
5557 await _seed_commit_list_repo(db_session)
5558 resp = await client.get(
5559 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?format=json"
5560 )
5561 assert resp.status_code == 200
5562 assert "application/json" in resp.headers["content-type"]
5563 body = resp.json()
5564 assert "commits" in body
5565 assert "total" in body
5566 assert body["total"] == 4
5567 assert len(body["commits"]) == 4
5568 # Commits are newest first; merge commit has timestamp now-1h (most recent)
5569 commit_ids = [c["commitId"] for c in body["commits"]]
5570 assert commit_ids[0] == _SHA_MAIN_MERGE
5571
5572
5573 @pytest.mark.anyio
5574 async def test_commits_list_page_json_pagination(
5575 client: AsyncClient,
5576 db_session: AsyncSession,
5577 ) -> None:
5578 """JSON with per_page=1&page=2 returns the second commit."""
5579 await _seed_commit_list_repo(db_session)
5580 resp = await client.get(
5581 f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits"
5582 "?format=json&per_page=1&page=2"
5583 )
5584 assert resp.status_code == 200
5585 body = resp.json()
5586 assert body["total"] == 4
5587 assert len(body["commits"]) == 1
5588 # Page 2 (newest-first) is the second most-recent commit.
5589 # Newest: _SHA_MAIN_MERGE (now-1h), then _SHA_MAIN_2 (now-2h)
5590 assert body["commits"][0]["commitId"] == _SHA_MAIN_2
5591
5592
5593 @pytest.mark.anyio
5594 async def test_commits_list_page_empty_state(
5595 client: AsyncClient,
5596 db_session: AsyncSession,
5597 ) -> None:
5598 """A repo with no commits shows the empty state message."""
5599 repo = MusehubRepo(
5600 name="empty-repo",
5601 owner="emptyowner",
5602 slug="empty-repo",
5603 visibility="public",
5604 owner_user_id="empty-owner-uid",
5605 )
5606 db_session.add(repo)
5607 await db_session.commit()
5608
5609 resp = await client.get("/emptyowner/empty-repo/commits")
5610 assert resp.status_code == 200
5611 assert "No commits yet" in resp.text or "muse push" in resp.text
5612
5613
5614 # ---------------------------------------------------------------------------
5615
5616
5617
5618 # ---------------------------------------------------------------------------
5619 # Commit detail enhancements — # ---------------------------------------------------------------------------
5620
5621
5622 async def _seed_commit_detail_fixtures(
5623 db_session: AsyncSession,
5624 ) -> tuple[str, str, str]:
5625 """Seed a public repo with a parent commit and a child commit.
5626
5627 Returns (repo_id, parent_commit_id, child_commit_id).
5628 """
5629 repo = MusehubRepo(
5630 name="commit-detail-test",
5631 owner="testuser",
5632 slug="commit-detail-test",
5633 visibility="public",
5634 owner_user_id="test-owner",
5635 )
5636 db_session.add(repo)
5637 await db_session.flush()
5638 repo_id = str(repo.repo_id)
5639
5640 branch = MusehubBranch(
5641 repo_id=repo_id,
5642 name="main",
5643 head_commit_id=None,
5644 )
5645 db_session.add(branch)
5646
5647 parent_commit_id = "aaaa0000111122223333444455556666aaaabbbb"
5648 child_commit_id = "bbbb1111222233334444555566667777bbbbcccc"
5649
5650 parent_commit = MusehubCommit(
5651 repo_id=repo_id,
5652 commit_id=parent_commit_id,
5653 branch="main",
5654 parent_ids=[],
5655 message="init: establish harmonic foundation in C major\n\nKey: C major\nBPM: 120\nMeter: 4/4",
5656 author="testuser",
5657 timestamp=datetime.now(UTC) - timedelta(hours=2),
5658 snapshot_id=None,
5659 )
5660 child_commit = MusehubCommit(
5661 repo_id=repo_id,
5662 commit_id=child_commit_id,
5663 branch="main",
5664 parent_ids=[parent_commit_id],
5665 message="feat(keys): add melodic piano phrase in D minor\n\nKey: D minor\nBPM: 132\nMeter: 3/4\nSection: verse",
5666 author="testuser",
5667 timestamp=datetime.now(UTC) - timedelta(hours=1),
5668 snapshot_id=None,
5669 )
5670 db_session.add(parent_commit)
5671 db_session.add(child_commit)
5672 await db_session.commit()
5673 return repo_id, parent_commit_id, child_commit_id
5674
5675
5676 @pytest.mark.anyio
5677 async def test_commit_detail_page_renders_enhanced_metadata(
5678 client: AsyncClient,
5679 db_session: AsyncSession,
5680 ) -> None:
5681 """Commit detail page SSR renders commit header fields (SHA, author, branch, parent link)."""
5682 await _seed_commit_detail_fixtures(db_session)
5683 sha = "bbbb1111222233334444555566667777bbbbcccc"
5684 response = await client.get(f"/testuser/commit-detail-test/commits/{sha}")
5685 assert response.status_code == 200
5686 assert "text/html" in response.headers["content-type"]
5687 body = response.text
5688 # SSR commit header — short SHA present
5689 assert "bbbb1111" in body
5690 # Author field rendered server-side
5691 assert "testuser" in body
5692 # Parent SHA navigation link present
5693 assert "aaaa0000" in body
5694
5695
5696 @pytest.mark.anyio
5697 async def test_commit_detail_audio_shell_with_snapshot_id(
5698 client: AsyncClient,
5699 db_session: AsyncSession,
5700 ) -> None:
5701 """Commit with snapshot_id gets a WaveSurfer shell rendered by the server."""
5702 from datetime import datetime, timezone
5703
5704 _repo_id, _parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5705 repo = MusehubRepo(
5706 name="audio-test-repo",
5707 owner="testuser",
5708 slug="audio-test-repo",
5709 visibility="public",
5710 owner_user_id="test-owner",
5711 )
5712 db_session.add(repo)
5713 await db_session.flush()
5714 snap_id = "sha256:deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678"
5715 commit_with_audio = MusehubCommit(
5716 commit_id="cccc2222333344445555666677778888ccccdddd",
5717 repo_id=str(repo.repo_id),
5718 branch="main",
5719 parent_ids=[],
5720 message="Commit with audio snapshot",
5721 author="testuser",
5722 timestamp=datetime.now(tz=timezone.utc),
5723 snapshot_id=snap_id,
5724 )
5725 db_session.add(commit_with_audio)
5726 await db_session.commit()
5727
5728 response = await client.get(
5729 f"/testuser/audio-test-repo/commits/cccc2222333344445555666677778888ccccdddd"
5730 )
5731 assert response.status_code == 200
5732 body = response.text
5733 # Commit page renders successfully; audio shell shown only for piano_roll domain repos
5734 assert "audio-test-repo" in body
5735 assert "Commit with audio snapshot" in body
5736
5737
5738 @pytest.mark.anyio
5739 async def test_commit_detail_ssr_message_present_in_body(
5740 client: AsyncClient,
5741 db_session: AsyncSession,
5742 ) -> None:
5743 """Commit message text is rendered in the SSR page body (replaces JS renderCommitBody)."""
5744 await _seed_commit_detail_fixtures(db_session)
5745 sha = "bbbb1111222233334444555566667777bbbbcccc"
5746 response = await client.get(f"/testuser/commit-detail-test/commits/{sha}")
5747 assert response.status_code == 200
5748 body = response.text
5749 # SSR renders the commit message directly — no JS renderCommitBody needed
5750 assert "feat(keys): add melodic piano phrase in D minor" in body
5751
5752
5753 @pytest.mark.anyio
5754 async def test_commit_detail_diff_summary_endpoint_returns_five_dimensions(
5755 client: AsyncClient,
5756 db_session: AsyncSession,
5757 auth_headers: dict[str, str],
5758 ) -> None:
5759 """GET /api/v1/repos/{repo_id}/commits/{sha}/diff-summary returns 5 dimensions."""
5760 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5761 response = await client.get(
5762 f"/api/v1/repos/{repo_id}/commits/{child_id}/diff-summary",
5763 headers=auth_headers,
5764 )
5765 assert response.status_code == 200
5766 data = response.json()
5767 assert data["commitId"] == child_id
5768 assert data["parentId"] == _parent_id
5769 assert "dimensions" in data
5770 assert len(data["dimensions"]) == 5
5771 dim_names = {d["dimension"] for d in data["dimensions"]}
5772 assert dim_names == {"harmonic", "rhythmic", "melodic", "structural", "dynamic"}
5773 for dim in data["dimensions"]:
5774 assert 0.0 <= dim["score"] <= 1.0
5775 assert dim["label"] in {"none", "low", "medium", "high"}
5776 assert dim["color"] in {"dim-none", "dim-low", "dim-medium", "dim-high"}
5777 assert "overallScore" in data
5778 assert 0.0 <= data["overallScore"] <= 1.0
5779
5780
5781 @pytest.mark.anyio
5782 async def test_commit_detail_diff_summary_root_commit_scores_one(
5783 client: AsyncClient,
5784 db_session: AsyncSession,
5785 auth_headers: dict[str, str],
5786 ) -> None:
5787 """Diff summary for a root commit (no parent) scores all dimensions at 1.0."""
5788 repo_id, parent_id, _child_id = await _seed_commit_detail_fixtures(db_session)
5789 response = await client.get(
5790 f"/api/v1/repos/{repo_id}/commits/{parent_id}/diff-summary",
5791 headers=auth_headers,
5792 )
5793 assert response.status_code == 200
5794 data = response.json()
5795 assert data["parentId"] is None
5796 for dim in data["dimensions"]:
5797 assert dim["score"] == 1.0
5798 assert dim["label"] == "high"
5799
5800
5801 @pytest.mark.anyio
5802 async def test_commit_detail_diff_summary_keyword_detection(
5803 client: AsyncClient,
5804 db_session: AsyncSession,
5805 auth_headers: dict[str, str],
5806 ) -> None:
5807 """Diff summary detects melodic keyword in child commit message."""
5808 repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session)
5809 response = await client.get(
5810 f"/api/v1/repos/{repo_id}/commits/{child_id}/diff-summary",
5811 headers=auth_headers,
5812 )
5813 assert response.status_code == 200
5814 data = response.json()
5815 melodic_dim = next(d for d in data["dimensions"] if d["dimension"] == "melodic")
5816 # child commit message contains "melodic" keyword → non-zero score
5817 assert melodic_dim["score"] > 0.0
5818
5819
5820 @pytest.mark.anyio
5821 async def test_commit_detail_diff_summary_unknown_commit_404(
5822 client: AsyncClient,
5823 db_session: AsyncSession,
5824 auth_headers: dict[str, str],
5825 ) -> None:
5826 """Diff summary for unknown commit ID returns 404."""
5827 repo_id, _p, _c = await _seed_commit_detail_fixtures(db_session)
5828 response = await client.get(
5829 f"/api/v1/repos/{repo_id}/commits/deadbeefdeadbeefdeadbeef/diff-summary",
5830 headers=auth_headers, )
5831 assert response.status_code == 404
5832
5833
5834 # ---------------------------------------------------------------------------
5835 # Commit comment threads — # ---------------------------------------------------------------------------
5836
5837
5838 @pytest.mark.anyio
5839 async def test_commit_page_has_comment_section_html(
5840 client: AsyncClient,
5841 db_session: AsyncSession,
5842 ) -> None:
5843 """Commit detail page HTML includes the HTMX comment target container."""
5844 from datetime import datetime, timezone
5845
5846 repo_id = await _make_repo(db_session)
5847 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5848 commit = MusehubCommit(
5849 commit_id=commit_id,
5850 repo_id=repo_id,
5851 branch="main",
5852 parent_ids=[],
5853 message="Add chorus section",
5854 author="testuser",
5855 timestamp=datetime.now(tz=timezone.utc),
5856 )
5857 db_session.add(commit)
5858 await db_session.commit()
5859
5860 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
5861 assert response.status_code == 200
5862 body = response.text
5863 # SSR replaces JS-loaded comment section with a server-rendered HTMX target
5864 assert "commit-comments" in body
5865 assert "hx-target" in body
5866
5867
5868 @pytest.mark.anyio
5869 async def test_commit_page_has_htmx_comment_form(
5870 client: AsyncClient,
5871 db_session: AsyncSession,
5872 ) -> None:
5873 """Commit detail page has an HTMX-driven comment form (replaces old JS comment functions)."""
5874 from datetime import datetime, timezone
5875
5876 repo_id = await _make_repo(db_session)
5877 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5878 commit = MusehubCommit(
5879 commit_id=commit_id,
5880 repo_id=repo_id,
5881 branch="main",
5882 parent_ids=[],
5883 message="Add chorus section",
5884 author="testuser",
5885 timestamp=datetime.now(tz=timezone.utc),
5886 )
5887 db_session.add(commit)
5888 await db_session.commit()
5889
5890 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
5891 assert response.status_code == 200
5892 body = response.text
5893 # HTMX form replaces JS renderComments/submitComment/loadComments
5894 assert "hx-post" in body
5895 assert "hx-target" in body
5896 assert "textarea" in body
5897
5898
5899 @pytest.mark.anyio
5900 async def test_commit_page_comment_htmx_target_present(
5901 client: AsyncClient,
5902 db_session: AsyncSession,
5903 ) -> None:
5904 """HTMX comment target div is present for server-side comment injection."""
5905 from datetime import datetime, timezone
5906
5907 repo_id = await _make_repo(db_session)
5908 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5909 commit = MusehubCommit(
5910 commit_id=commit_id,
5911 repo_id=repo_id,
5912 branch="main",
5913 parent_ids=[],
5914 message="Add chorus section",
5915 author="testuser",
5916 timestamp=datetime.now(tz=timezone.utc),
5917 )
5918 db_session.add(commit)
5919 await db_session.commit()
5920
5921 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
5922 assert response.status_code == 200
5923 body = response.text
5924 assert 'id="commit-comments"' in body
5925
5926
5927 @pytest.mark.anyio
5928 async def test_commit_page_comment_htmx_posts_to_comments_endpoint(
5929 client: AsyncClient,
5930 db_session: AsyncSession,
5931 ) -> None:
5932 """HTMX form posts to the commit comments endpoint (replaces old JS API fetch)."""
5933 from datetime import datetime, timezone
5934
5935 repo_id = await _make_repo(db_session)
5936 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5937 commit = MusehubCommit(
5938 commit_id=commit_id,
5939 repo_id=repo_id,
5940 branch="main",
5941 parent_ids=[],
5942 message="Add chorus section",
5943 author="testuser",
5944 timestamp=datetime.now(tz=timezone.utc),
5945 )
5946 db_session.add(commit)
5947 await db_session.commit()
5948
5949 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
5950 assert response.status_code == 200
5951 body = response.text
5952 assert "hx-post" in body
5953 assert "/comments" in body
5954
5955
5956 @pytest.mark.anyio
5957 async def test_commit_page_comment_has_ssr_avatar(
5958 client: AsyncClient,
5959 db_session: AsyncSession,
5960 ) -> None:
5961 """Commit page SSR comment thread renders avatar initials via server-side template."""
5962 from datetime import datetime, timezone
5963
5964 repo_id = await _make_repo(db_session)
5965 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5966 commit = MusehubCommit(
5967 commit_id=commit_id,
5968 repo_id=repo_id,
5969 branch="main",
5970 parent_ids=[],
5971 message="Add chorus section",
5972 author="testuser",
5973 timestamp=datetime.now(tz=timezone.utc),
5974 )
5975 db_session.add(commit)
5976 await db_session.commit()
5977
5978 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
5979 assert response.status_code == 200
5980 body = response.text
5981 # comment-avatar only rendered when comments exist; check commit page structure
5982 assert "commit-detail" in body or "page-data" in body
5983
5984
5985 @pytest.mark.anyio
5986 async def test_commit_page_comment_has_htmx_form_elements(
5987 client: AsyncClient,
5988 db_session: AsyncSession,
5989 ) -> None:
5990 """Commit page HTMX comment form has textarea and submit button."""
5991 from datetime import datetime, timezone
5992
5993 repo_id = await _make_repo(db_session)
5994 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
5995 commit = MusehubCommit(
5996 commit_id=commit_id,
5997 repo_id=repo_id,
5998 branch="main",
5999 parent_ids=[],
6000 message="Add chorus section",
6001 author="testuser",
6002 timestamp=datetime.now(tz=timezone.utc),
6003 )
6004 db_session.add(commit)
6005 await db_session.commit()
6006
6007 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6008 assert response.status_code == 200
6009 body = response.text
6010 # HTMX form replaces old new-comment-form/new-comment-body/comment-submit-btn
6011 assert 'name="body"' in body
6012 assert "btn-primary" in body
6013 assert "Comment" in body
6014
6015
6016 @pytest.mark.anyio
6017 async def test_commit_page_comment_section_shows_count_heading(
6018 client: AsyncClient,
6019 db_session: AsyncSession,
6020 ) -> None:
6021 """Commit page SSR comment section shows a count heading (replaces 'Discussion' heading)."""
6022 from datetime import datetime, timezone
6023
6024 repo_id = await _make_repo(db_session)
6025 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6026 commit = MusehubCommit(
6027 commit_id=commit_id,
6028 repo_id=repo_id,
6029 branch="main",
6030 parent_ids=[],
6031 message="Add chorus section",
6032 author="testuser",
6033 timestamp=datetime.now(tz=timezone.utc),
6034 )
6035 db_session.add(commit)
6036 await db_session.commit()
6037
6038 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6039 assert response.status_code == 200
6040 body = response.text
6041 assert "comment" in body
6042
6043
6044 # ---------------------------------------------------------------------------
6045 # Commit detail enhancements — ref URL links, DB tags in panel, prose
6046 # summary
6047 # ---------------------------------------------------------------------------
6048
6049
6050 @pytest.mark.anyio
6051 async def test_commit_page_ssr_renders_commit_message(
6052 client: AsyncClient,
6053 db_session: AsyncSession,
6054 ) -> None:
6055 """Commit message is rendered server-side (replaces JS ref-tag / tagPill rendering)."""
6056 from datetime import datetime, timezone
6057
6058 repo_id = await _make_repo(db_session)
6059 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6060 commit = MusehubCommit(
6061 commit_id=commit_id,
6062 repo_id=repo_id,
6063 branch="main",
6064 parent_ids=[],
6065 message="Unique groove message XYZ",
6066 author="testuser",
6067 timestamp=datetime.now(tz=timezone.utc),
6068 )
6069 db_session.add(commit)
6070 await db_session.commit()
6071
6072 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6073 assert response.status_code == 200
6074 body = response.text
6075 # SSR renders commit message directly — no JS tagPill/isRefUrl needed
6076 assert "Unique groove message XYZ" in body
6077
6078
6079 @pytest.mark.anyio
6080 async def test_commit_page_ssr_renders_author_metadata(
6081 client: AsyncClient,
6082 db_session: AsyncSession,
6083 ) -> None:
6084 """Commit author and branch appear in the SSR metadata grid (replaces JS muse-tags panel)."""
6085 from datetime import datetime, timezone
6086
6087 repo_id = await _make_repo(db_session)
6088 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6089 commit = MusehubCommit(
6090 commit_id=commit_id,
6091 repo_id=repo_id,
6092 branch="main",
6093 parent_ids=[],
6094 message="Add chorus section",
6095 author="jazzproducer",
6096 timestamp=datetime.now(tz=timezone.utc),
6097 )
6098 db_session.add(commit)
6099 await db_session.commit()
6100
6101 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6102 assert response.status_code == 200
6103 body = response.text
6104 # SSR metadata grid shows author — no JS loadMuseTagsPanel needed
6105 assert "jazzproducer" in body
6106
6107
6108 @pytest.mark.anyio
6109 async def test_commit_page_no_audio_shell_when_no_snapshot(
6110 client: AsyncClient,
6111 db_session: AsyncSession,
6112 ) -> None:
6113 """Commit page without snapshot_id omits WaveSurfer shell (replaces buildProseSummary check)."""
6114 from datetime import datetime, timezone
6115
6116 repo_id = await _make_repo(db_session)
6117 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
6118 commit = MusehubCommit(
6119 commit_id=commit_id,
6120 repo_id=repo_id,
6121 branch="main",
6122 parent_ids=[],
6123 message="Add chorus section",
6124 author="testuser",
6125 timestamp=datetime.now(tz=timezone.utc),
6126 snapshot_id=None,
6127 )
6128 db_session.add(commit)
6129 await db_session.commit()
6130
6131 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
6132 assert response.status_code == 200
6133 body = response.text
6134 assert "commit-waveform" not in body
6135
6136
6137 # ---------------------------------------------------------------------------
6138 # Audio player — listen page tests
6139 # ---------------------------------------------------------------------------
6140
6141
6142 @pytest.mark.anyio
6143 async def test_listen_page_renders(
6144 client: AsyncClient,
6145 db_session: AsyncSession,
6146 ) -> None:
6147 """GET /{owner}/{slug}/view/{ref} must return 200 HTML."""
6148 await _make_repo(db_session)
6149 ref = "abc1234567890abcdef"
6150 response = await client.get(f"/testuser/test-beats/view/{ref}")
6151 assert response.status_code == 200
6152 assert "text/html" in response.headers["content-type"]
6153
6154
6155 @pytest.mark.anyio
6156 async def test_listen_page_no_auth_required(
6157 client: AsyncClient,
6158 db_session: AsyncSession,
6159 ) -> None:
6160 """Listen page must be accessible without an Authorization header."""
6161 await _make_repo(db_session)
6162 ref = "deadbeef1234"
6163 response = await client.get(f"/testuser/test-beats/view/{ref}")
6164 assert response.status_code != 401
6165 assert response.status_code == 200
6166
6167
6168 @pytest.mark.anyio
6169 async def test_listen_page_contains_waveform_ui(
6170 client: AsyncClient,
6171 db_session: AsyncSession,
6172 ) -> None:
6173 """Listen page HTML must contain the page config for listen.ts."""
6174 await _make_repo(db_session)
6175 ref = "cafebabe1234"
6176 response = await client.get(f"/testuser/test-beats/view/{ref}")
6177 assert response.status_code == 200
6178 body = response.text
6179 assert '"viewerType"' in body
6180
6181
6182 @pytest.mark.anyio
6183 async def test_listen_page_contains_play_button(
6184 client: AsyncClient,
6185 db_session: AsyncSession,
6186 ) -> None:
6187 """Listen page must dispatch the listen.ts module via page config."""
6188 await _make_repo(db_session)
6189 ref = "feed1234abcdef"
6190 response = await client.get(f"/testuser/test-beats/view/{ref}")
6191 assert response.status_code == 200
6192 body = response.text
6193 assert '"viewerType"' in body
6194
6195
6196 @pytest.mark.anyio
6197 async def test_listen_page_contains_speed_selector(
6198 client: AsyncClient,
6199 db_session: AsyncSession,
6200 ) -> None:
6201 """Listen page must dispatch the listen.ts module (speed selector is client-side)."""
6202 await _make_repo(db_session)
6203 ref = "1a2b3c4d5e6f7890"
6204 response = await client.get(f"/testuser/test-beats/view/{ref}")
6205 assert response.status_code == 200
6206 body = response.text
6207 assert '"viewerType"' in body
6208
6209
6210 @pytest.mark.anyio
6211 async def test_listen_page_contains_ab_loop_ui(
6212 client: AsyncClient,
6213 db_session: AsyncSession,
6214 ) -> None:
6215 """Listen page must dispatch the listen.ts module (A/B loop controls are client-side)."""
6216 await _make_repo(db_session)
6217 ref = "aabbccddeeff0011"
6218 response = await client.get(f"/testuser/test-beats/view/{ref}")
6219 assert response.status_code == 200
6220 body = response.text
6221 assert '"viewerType"' in body
6222
6223
6224 @pytest.mark.anyio
6225 async def test_listen_page_loads_wavesurfer_vendor(
6226 client: AsyncClient,
6227 db_session: AsyncSession,
6228 ) -> None:
6229 """Listen page must dispatch the listen.ts module (WaveSurfer loaded via app.js bundle)."""
6230 await _make_repo(db_session)
6231 ref = "112233445566778899"
6232 response = await client.get(f"/testuser/test-beats/view/{ref}")
6233 assert response.status_code == 200
6234 body = response.text
6235 # WaveSurfer is now bundled/loaded by listen.ts, not as a separate script tag
6236 assert '"viewerType"' in body
6237 # wavesurfer must NOT be loaded from an external CDN
6238 assert "unpkg.com/wavesurfer" not in body
6239 assert "cdn.jsdelivr.net/wavesurfer" not in body
6240 assert "cdnjs.cloudflare.com/ajax/libs/wavesurfer" not in body
6241
6242
6243 @pytest.mark.anyio
6244 async def test_listen_page_loads_audio_player_js(
6245 client: AsyncClient,
6246 db_session: AsyncSession,
6247 ) -> None:
6248 """Listen page must dispatch the listen.ts module (audio player is now bundled in app.js)."""
6249 await _make_repo(db_session)
6250 ref = "99aabbccddeeff00"
6251 response = await client.get(f"/testuser/test-beats/view/{ref}")
6252 assert response.status_code == 200
6253 body = response.text
6254 assert '"viewerType"' in body
6255
6256
6257 @pytest.mark.anyio
6258 async def test_listen_track_page_renders(
6259 client: AsyncClient,
6260 db_session: AsyncSession,
6261 ) -> None:
6262 """GET /{owner}/{slug}/view/{ref}/{path} must return 200."""
6263 await _make_repo(db_session)
6264 ref = "feedface0011aabb"
6265 response = await client.get(
6266 f"/testuser/test-beats/view/{ref}/tracks/bass.mp3"
6267 )
6268 assert response.status_code == 200
6269 assert "text/html" in response.headers["content-type"]
6270
6271
6272 @pytest.mark.anyio
6273 async def test_listen_track_page_has_track_path_in_js(
6274 client: AsyncClient,
6275 db_session: AsyncSession,
6276 ) -> None:
6277 """Track path is passed via page config JSON to listen.ts, not as a JS variable."""
6278 await _make_repo(db_session)
6279 ref = "00aabbccddeeff11"
6280 track = "tracks/lead-guitar.mp3"
6281 response = await client.get(
6282 f"/testuser/test-beats/view/{ref}/{track}"
6283 )
6284 assert response.status_code == 200
6285 body = response.text
6286 assert '"viewerType"' in body
6287 assert "lead-guitar.mp3" in body
6288
6289
6290 @pytest.mark.anyio
6291 async def test_listen_page_unknown_repo_404(
6292 client: AsyncClient,
6293 db_session: AsyncSession,
6294 ) -> None:
6295 """GET listen page with nonexistent owner/slug must return 404."""
6296 response = await client.get(
6297 "/nobody/nonexistent-repo/view/abc123"
6298 )
6299 assert response.status_code == 404
6300
6301
6302 @pytest.mark.anyio
6303 async def test_listen_page_keyboard_shortcuts_documented(
6304 client: AsyncClient,
6305 db_session: AsyncSession,
6306 ) -> None:
6307 """Listen page dispatches listen.ts (keyboard shortcuts handled client-side)."""
6308 await _make_repo(db_session)
6309 ref = "cafe0011aabb2233"
6310 response = await client.get(f"/testuser/test-beats/view/{ref}")
6311 assert response.status_code == 200
6312 body = response.text
6313 assert '"viewerType"' in body
6314
6315
6316 # ---------------------------------------------------------------------------
6317 # Compare view
6318 # ---------------------------------------------------------------------------
6319
6320
6321 @pytest.mark.anyio
6322 async def test_compare_page_renders(
6323 client: AsyncClient,
6324 db_session: AsyncSession,
6325 ) -> None:
6326 """GET /{owner}/{slug}/compare/{base}...{head} returns 200 HTML."""
6327 await _make_repo(db_session)
6328 response = await client.get("/testuser/test-beats/compare/main...feature")
6329 assert response.status_code == 200
6330 assert "text/html" in response.headers["content-type"]
6331 body = response.text
6332 assert "MuseHub" in body
6333 assert "main" in body
6334 assert "feature" in body
6335
6336
6337 @pytest.mark.anyio
6338 async def test_compare_page_no_auth_required(
6339 client: AsyncClient,
6340 db_session: AsyncSession,
6341 ) -> None:
6342 """Compare page is accessible without a JWT token."""
6343 await _make_repo(db_session)
6344 response = await client.get("/testuser/test-beats/compare/main...feature")
6345 assert response.status_code == 200
6346
6347
6348 @pytest.mark.anyio
6349 async def test_compare_page_invalid_ref_404(
6350 client: AsyncClient,
6351 db_session: AsyncSession,
6352 ) -> None:
6353 """Compare path without '...' separator returns 404."""
6354 await _make_repo(db_session)
6355 response = await client.get("/testuser/test-beats/compare/mainfeature")
6356 assert response.status_code == 404
6357
6358
6359 @pytest.mark.anyio
6360 async def test_compare_page_unknown_owner_404(
6361 client: AsyncClient,
6362 ) -> None:
6363 """Unknown owner/slug combination returns 404 on compare page."""
6364 response = await client.get("/nobody/norepo/compare/main...feature")
6365 assert response.status_code == 404
6366
6367
6368 @pytest.mark.anyio
6369 async def test_compare_page_includes_radar(
6370 client: AsyncClient,
6371 db_session: AsyncSession,
6372 ) -> None:
6373 """Compare page SSR HTML contains all five musical dimension names (replaces JS radar).
6374
6375 The compare page now renders data server-side via a dimension table.
6376 Musical dimensions (Melodic, Harmonic, etc.) must appear in the HTML body
6377 before any client-side JavaScript runs.
6378 """
6379 await _make_repo(db_session)
6380 response = await client.get("/testuser/test-beats/compare/main...feature")
6381 assert response.status_code == 200
6382 body = response.text
6383 assert "Melodic" in body
6384 assert "Harmonic" in body
6385
6386
6387 @pytest.mark.anyio
6388 async def test_compare_page_includes_piano_roll(
6389 client: AsyncClient,
6390 db_session: AsyncSession,
6391 ) -> None:
6392 """Compare page SSR HTML contains the dimension table (replaces piano roll JS panel).
6393
6394 The compare page now renders a dimension comparison table server-side.
6395 Both ref names must appear as column headers in the HTML.
6396 """
6397 await _make_repo(db_session)
6398 response = await client.get("/testuser/test-beats/compare/main...feature")
6399 assert response.status_code == 200
6400 body = response.text
6401 assert "main" in body
6402 assert "feature" in body
6403 assert "Dimension" in body
6404
6405
6406 @pytest.mark.anyio
6407 async def test_compare_page_includes_emotion_diff(
6408 client: AsyncClient,
6409 db_session: AsyncSession,
6410 ) -> None:
6411 """Compare page SSR HTML contains change delta column (replaces emotion diff JS).
6412
6413 The dimension table includes a Change column showing delta values server-side.
6414 """
6415 await _make_repo(db_session)
6416 response = await client.get("/testuser/test-beats/compare/main...feature")
6417 assert response.status_code == 200
6418 body = response.text
6419 assert "Change" in body
6420 assert "%" in body
6421
6422
6423 @pytest.mark.anyio
6424 async def test_compare_page_includes_commit_list(
6425 client: AsyncClient,
6426 db_session: AsyncSession,
6427 ) -> None:
6428 """Compare page SSR HTML contains dimension rows (replaces client-side commit list JS).
6429
6430 All five musical dimensions must appear as data rows in the server-rendered table.
6431 """
6432 await _make_repo(db_session)
6433 response = await client.get("/testuser/test-beats/compare/main...feature")
6434 assert response.status_code == 200
6435 body = response.text
6436 assert "Rhythmic" in body
6437 assert "Structural" in body
6438 assert "Dynamic" in body
6439
6440
6441 @pytest.mark.anyio
6442 async def test_compare_page_includes_create_pr_button(
6443 client: AsyncClient,
6444 db_session: AsyncSession,
6445 ) -> None:
6446 """Compare page SSR HTML contains both ref names in the heading (replaces PR button CTA).
6447
6448 The SSR compare page shows the base and head refs in the page header.
6449 """
6450 await _make_repo(db_session)
6451 response = await client.get("/testuser/test-beats/compare/main...feature")
6452 assert response.status_code == 200
6453 body = response.text
6454 assert "Compare" in body
6455 assert "main" in body
6456 assert "feature" in body
6457
6458
6459 @pytest.mark.anyio
6460 async def test_compare_json_response(
6461 client: AsyncClient,
6462 db_session: AsyncSession,
6463 ) -> None:
6464 """GET /{owner}/{slug}/compare/{refs} returns HTML with SSR dimension data.
6465
6466 The compare page is now fully SSR — no JSON format negotiation.
6467 The response is always text/html containing the dimension table.
6468 """
6469 await _make_repo(db_session)
6470 response = await client.get("/testuser/test-beats/compare/main...feature")
6471 assert response.status_code == 200
6472 assert "text/html" in response.headers["content-type"]
6473 body = response.text
6474 assert "Melodic" in body
6475 assert "main" in body
6476
6477
6478 # ---------------------------------------------------------------------------
6479 # Issue #208 — Branch list and tag browser tests
6480 # ---------------------------------------------------------------------------
6481
6482
6483 async def _make_repo_with_branches(
6484 db_session: AsyncSession,
6485 ) -> tuple[str, str, str]:
6486 """Seed a repo with two branches (main + feature) and return (repo_id, owner, slug)."""
6487 repo = MusehubRepo(
6488 name="branch-test",
6489 owner="testuser",
6490 slug="branch-test",
6491 visibility="private",
6492 owner_user_id="test-owner",
6493 )
6494 db_session.add(repo)
6495 await db_session.flush()
6496 repo_id = str(repo.repo_id)
6497
6498 main_branch = MusehubBranch(repo_id=repo_id, name="main", head_commit_id="aaa000")
6499 feat_branch = MusehubBranch(repo_id=repo_id, name="feat/jazz-bridge", head_commit_id="bbb111")
6500 db_session.add_all([main_branch, feat_branch])
6501
6502 # Two commits on main, one unique commit on feat/jazz-bridge
6503 now = datetime.now(UTC)
6504 c1 = MusehubCommit(
6505 commit_id="aaa000",
6506 repo_id=repo_id,
6507 branch="main",
6508 parent_ids=[],
6509 message="Initial commit",
6510 author="composer@muse.app",
6511 timestamp=now,
6512 )
6513 c2 = MusehubCommit(
6514 commit_id="aaa001",
6515 repo_id=repo_id,
6516 branch="main",
6517 parent_ids=["aaa000"],
6518 message="Add bridge",
6519 author="composer@muse.app",
6520 timestamp=now,
6521 )
6522 c3 = MusehubCommit(
6523 commit_id="bbb111",
6524 repo_id=repo_id,
6525 branch="feat/jazz-bridge",
6526 parent_ids=["aaa000"],
6527 message="Add jazz chord",
6528 author="composer@muse.app",
6529 timestamp=now,
6530 )
6531 db_session.add_all([c1, c2, c3])
6532 await db_session.commit()
6533 return repo_id, "testuser", "branch-test"
6534
6535
6536 async def _make_repo_with_releases(
6537 db_session: AsyncSession,
6538 ) -> tuple[str, str, str]:
6539 """Seed a repo with namespaced releases used as tags."""
6540 repo = MusehubRepo(
6541 name="tag-test",
6542 owner="testuser",
6543 slug="tag-test",
6544 visibility="private",
6545 owner_user_id="test-owner",
6546 )
6547 db_session.add(repo)
6548 await db_session.flush()
6549 repo_id = str(repo.repo_id)
6550
6551 now = datetime.now(UTC)
6552 releases = [
6553 MusehubRelease(
6554 repo_id=repo_id, tag="emotion:happy", title="Happy vibes", body="",
6555 commit_id="abc001", author="composer", created_at=now, download_urls={},
6556 ),
6557 MusehubRelease(
6558 repo_id=repo_id, tag="genre:jazz", title="Jazz release", body="",
6559 commit_id="abc002", author="composer", created_at=now, download_urls={},
6560 ),
6561 MusehubRelease(
6562 repo_id=repo_id, tag="v1.0", title="Version 1.0", body="",
6563 commit_id="abc003", author="composer", created_at=now, download_urls={},
6564 ),
6565 ]
6566 db_session.add_all(releases)
6567 await db_session.commit()
6568 return repo_id, "testuser", "tag-test"
6569
6570
6571 @pytest.mark.anyio
6572 async def test_branches_page_lists_all(
6573 client: AsyncClient,
6574 db_session: AsyncSession,
6575 ) -> None:
6576 """GET /{owner}/{slug}/branches returns 200 HTML."""
6577 await _make_repo_with_branches(db_session)
6578 resp = await client.get("/testuser/branch-test/branches")
6579 assert resp.status_code == 200
6580 assert "text/html" in resp.headers["content-type"]
6581 body = resp.text
6582 assert "MuseHub" in body
6583 # Page-specific JS identifiers
6584 assert "branch-row" in body or "branches" in body.lower()
6585
6586
6587 @pytest.mark.anyio
6588 async def test_branches_default_marked(
6589 client: AsyncClient,
6590 db_session: AsyncSession,
6591 ) -> None:
6592 """JSON response marks the default branch with isDefault=true."""
6593 await _make_repo_with_branches(db_session)
6594 resp = await client.get(
6595 "/testuser/branch-test/branches",
6596 headers={"Accept": "application/json"},
6597 )
6598 assert resp.status_code == 200
6599 data = resp.json()
6600 assert "branches" in data
6601 default_branches = [b for b in data["branches"] if b.get("isDefault")]
6602 assert len(default_branches) == 1
6603 assert default_branches[0]["name"] == "main"
6604
6605
6606 @pytest.mark.anyio
6607 async def test_branches_compare_link(
6608 client: AsyncClient,
6609 db_session: AsyncSession,
6610 ) -> None:
6611 """Branches page HTML contains compare link JavaScript."""
6612 await _make_repo_with_branches(db_session)
6613 resp = await client.get("/testuser/branch-test/branches")
6614 assert resp.status_code == 200
6615 body = resp.text
6616 # The JS template must reference the compare URL pattern
6617 assert "compare" in body.lower()
6618
6619
6620 @pytest.mark.anyio
6621 async def test_branches_new_pr_button(
6622 client: AsyncClient,
6623 db_session: AsyncSession,
6624 ) -> None:
6625 """Branches page HTML contains New Pull Request link JavaScript."""
6626 await _make_repo_with_branches(db_session)
6627 resp = await client.get("/testuser/branch-test/branches")
6628 assert resp.status_code == 200
6629 body = resp.text
6630 assert "Pull Request" in body
6631
6632
6633 @pytest.mark.anyio
6634 async def test_branches_json_response(
6635 client: AsyncClient,
6636 db_session: AsyncSession,
6637 ) -> None:
6638 """JSON response includes branches with ahead/behind counts and divergence placeholder."""
6639 await _make_repo_with_branches(db_session)
6640 resp = await client.get(
6641 "/testuser/branch-test/branches?format=json",
6642 )
6643 assert resp.status_code == 200
6644 data = resp.json()
6645 assert "branches" in data
6646 assert "defaultBranch" in data
6647 assert data["defaultBranch"] == "main"
6648
6649 branches_by_name = {b["name"]: b for b in data["branches"]}
6650 assert "main" in branches_by_name
6651 assert "feat/jazz-bridge" in branches_by_name
6652
6653 main = branches_by_name["main"]
6654 assert main["isDefault"] is True
6655 assert main["aheadCount"] == 0
6656 assert main["behindCount"] == 0
6657
6658 feat = branches_by_name["feat/jazz-bridge"]
6659 assert feat["isDefault"] is False
6660 # feat has 1 unique commit (bbb111); main has 2 commits (aaa000, aaa001) not shared with feat
6661 assert feat["aheadCount"] == 1
6662 assert feat["behindCount"] == 2
6663
6664 # Divergence is a placeholder (all None)
6665 div = feat["divergence"]
6666 assert div["melodic"] is None
6667 assert div["harmonic"] is None
6668
6669
6670 @pytest.mark.anyio
6671 async def test_tags_page_lists_all(
6672 client: AsyncClient,
6673 db_session: AsyncSession,
6674 ) -> None:
6675 """GET /{owner}/{slug}/tags returns 200 HTML."""
6676 await _make_repo_with_releases(db_session)
6677 resp = await client.get("/testuser/tag-test/tags")
6678 assert resp.status_code == 200
6679 assert "text/html" in resp.headers["content-type"]
6680 body = resp.text
6681 assert "MuseHub" in body
6682 assert "Tags" in body
6683
6684
6685 @pytest.mark.anyio
6686 async def test_tags_namespace_filter(
6687 client: AsyncClient,
6688 db_session: AsyncSession,
6689 ) -> None:
6690 """Tags page HTML includes namespace filter dropdown JavaScript."""
6691 await _make_repo_with_releases(db_session)
6692 resp = await client.get("/testuser/tag-test/tags")
6693 assert resp.status_code == 200
6694 body = resp.text
6695 # Namespace filter select element is rendered by JS
6696 assert "ns-filter" in body or "namespace" in body.lower()
6697 # Namespace icons present
6698 assert "&#127768;" in body or "emotion" in body
6699
6700
6701 @pytest.mark.anyio
6702 async def test_tags_json_response(
6703 client: AsyncClient,
6704 db_session: AsyncSession,
6705 ) -> None:
6706 """JSON response returns TagListResponse with namespace grouping."""
6707 await _make_repo_with_releases(db_session)
6708 resp = await client.get(
6709 "/testuser/tag-test/tags?format=json",
6710 )
6711 assert resp.status_code == 200
6712 data = resp.json()
6713 assert "tags" in data
6714 assert "namespaces" in data
6715
6716 # All three releases become tags
6717 assert len(data["tags"]) == 3
6718
6719 tags_by_name = {t["tag"]: t for t in data["tags"]}
6720 assert "emotion:happy" in tags_by_name
6721 assert "genre:jazz" in tags_by_name
6722 assert "v1.0" in tags_by_name
6723
6724 assert tags_by_name["emotion:happy"]["namespace"] == "emotion"
6725 assert tags_by_name["genre:jazz"]["namespace"] == "genre"
6726 assert tags_by_name["v1.0"]["namespace"] == "version"
6727
6728 # Namespaces are sorted
6729 assert sorted(data["namespaces"]) == data["namespaces"]
6730 assert "emotion" in data["namespaces"]
6731 assert "genre" in data["namespaces"]
6732 assert "version" in data["namespaces"]
6733
6734
6735
6736 # ---------------------------------------------------------------------------
6737 # Arrangement matrix page — # ---------------------------------------------------------------------------
6738
6739
6740 # ---------------------------------------------------------------------------
6741 # Piano roll page tests — # ---------------------------------------------------------------------------
6742
6743
6744 @pytest.mark.anyio
6745 async def test_arrange_page_returns_200(
6746 client: AsyncClient,
6747 db_session: AsyncSession,
6748 ) -> None:
6749 """GET /{owner}/{slug}/view/{ref} returns 200 HTML without a JWT."""
6750 await _make_repo(db_session)
6751 response = await client.get("/testuser/test-beats/view/HEAD")
6752 assert response.status_code == 200
6753 assert "text/html" in response.headers["content-type"]
6754
6755
6756 @pytest.mark.anyio
6757 async def test_piano_roll_page_returns_200(
6758 client: AsyncClient,
6759 db_session: AsyncSession,
6760 ) -> None:
6761 """GET /{owner}/{slug}/view/{ref} returns 200 HTML."""
6762 await _make_repo(db_session)
6763 response = await client.get("/testuser/test-beats/view/main")
6764 assert response.status_code == 200
6765 assert "text/html" in response.headers["content-type"]
6766
6767
6768 @pytest.mark.anyio
6769 async def test_arrange_page_no_auth_required(
6770 client: AsyncClient,
6771 db_session: AsyncSession,
6772 ) -> None:
6773 """Arrangement matrix page is accessible without a JWT (auth handled client-side)."""
6774 await _make_repo(db_session)
6775 response = await client.get("/testuser/test-beats/view/HEAD")
6776 assert response.status_code == 200
6777 assert response.status_code != 401
6778
6779
6780 @pytest.mark.anyio
6781 async def test_arrange_page_contains_musehub(
6782 client: AsyncClient,
6783 db_session: AsyncSession,
6784 ) -> None:
6785 """Arrangement matrix page HTML shell contains 'MuseHub' branding."""
6786 await _make_repo(db_session)
6787 response = await client.get("/testuser/test-beats/view/abc1234")
6788 assert response.status_code == 200
6789 assert "MuseHub" in response.text
6790
6791
6792 @pytest.mark.anyio
6793 async def test_arrange_page_contains_grid_js(
6794 client: AsyncClient,
6795 db_session: AsyncSession,
6796 ) -> None:
6797 """Insights page (aliases /view) renders the domain viewer container."""
6798 await _make_repo(db_session)
6799 response = await client.get("/testuser/test-beats/view/HEAD")
6800 assert response.status_code == 200
6801 body = response.text
6802 assert "ins-page" in body
6803
6804
6805 @pytest.mark.anyio
6806 async def test_arrange_page_contains_density_logic(
6807 client: AsyncClient,
6808 db_session: AsyncSession,
6809 ) -> None:
6810 """Domain view page embeds the viewerType in page config JSON."""
6811 await _make_repo(db_session)
6812 response = await client.get("/testuser/test-beats/view/HEAD")
6813 assert response.status_code == 200
6814 body = response.text
6815 assert "viewerType" in body
6816
6817
6818 @pytest.mark.anyio
6819 async def test_arrange_page_contains_token_form(
6820 client: AsyncClient,
6821 db_session: AsyncSession,
6822 ) -> None:
6823 """Insights page (aliases /view) renders successfully with MuseHub branding."""
6824 await _make_repo(db_session)
6825 response = await client.get("/testuser/test-beats/view/HEAD")
6826 assert response.status_code == 200
6827 body = response.text
6828 assert "MuseHub" in body
6829 assert "ins-page" in body
6830
6831
6832 @pytest.mark.anyio
6833 async def test_arrange_page_unknown_repo_returns_404(
6834 client: AsyncClient,
6835 db_session: AsyncSession,
6836 ) -> None:
6837 """GET /{unknown}/{slug}/view/{ref} returns 404 for unknown repos."""
6838 response = await client.get("/unknown-user/no-such-repo/view/HEAD")
6839 assert response.status_code == 404
6840
6841
6842 @pytest.mark.anyio
6843 async def test_commit_detail_unknown_format_param_returns_html(
6844 client: AsyncClient,
6845 db_session: AsyncSession,
6846 ) -> None:
6847 """GET commit detail page ignores ?format=json — SSR always returns HTML."""
6848 await _seed_commit_detail_fixtures(db_session)
6849 sha = "bbbb1111222233334444555566667777bbbbcccc"
6850 response = await client.get(
6851 f"/testuser/commit-detail-test/commits/{sha}?format=json"
6852 )
6853 assert response.status_code == 200
6854 assert "text/html" in response.headers["content-type"]
6855 # SSR commit page — commit message appears in body
6856 assert "feat(keys)" in response.text
6857
6858
6859 @pytest.mark.anyio
6860 async def test_commit_detail_wavesurfer_js_conditional_on_audio_url(
6861 client: AsyncClient,
6862 db_session: AsyncSession,
6863 ) -> None:
6864 """WaveSurfer JS block is only present when audio_url is set (replaces musicalMeta JS checks)."""
6865 await _seed_commit_detail_fixtures(db_session)
6866 sha = "bbbb1111222233334444555566667777bbbbcccc"
6867 response = await client.get(f"/testuser/commit-detail-test/commits/{sha}")
6868 assert response.status_code == 200
6869 body = response.text
6870 # The child commit has no snapshot_id in _seed_commit_detail_fixtures → no WaveSurfer
6871 assert "commit-waveform" not in body
6872 # WaveSurfer script only loaded when audio is present — not here
6873 assert "wavesurfer.min.js" not in body
6874
6875
6876 @pytest.mark.anyio
6877 async def test_commit_detail_nav_has_parent_link(
6878 client: AsyncClient,
6879 db_session: AsyncSession,
6880 ) -> None:
6881 """Commit detail page navigation includes the parent commit link (SSR)."""
6882 await _seed_commit_detail_fixtures(db_session)
6883 sha = "bbbb1111222233334444555566667777bbbbcccc"
6884 response = await client.get(f"/testuser/commit-detail-test/commits/{sha}")
6885 assert response.status_code == 200
6886 body = response.text
6887 # SSR renders parent commit link when parent_ids is non-empty
6888 assert "Parent" in body
6889 # Parent SHA abbreviated to 8 chars in href
6890 assert "aaaa0000" in body
6891
6892
6893 @pytest.mark.anyio
6894 async def test_piano_roll_page_no_auth_required(
6895 client: AsyncClient,
6896 db_session: AsyncSession,
6897 ) -> None:
6898 """Piano roll UI page is accessible without a JWT token."""
6899 await _make_repo(db_session)
6900 response = await client.get("/testuser/test-beats/view/main")
6901 assert response.status_code == 200
6902
6903
6904 @pytest.mark.anyio
6905 async def test_piano_roll_page_loads_piano_roll_js(
6906 client: AsyncClient,
6907 db_session: AsyncSession,
6908 ) -> None:
6909 """View page embeds the viewerType in page config JSON."""
6910 await _make_repo(db_session)
6911 response = await client.get("/testuser/test-beats/view/main")
6912 assert response.status_code == 200
6913 assert "viewerType" in response.text
6914
6915
6916 @pytest.mark.anyio
6917 async def test_piano_roll_page_contains_canvas(
6918 client: AsyncClient,
6919 db_session: AsyncSession,
6920 ) -> None:
6921 """Insights page (aliases /view) renders the domain viewer container server-side."""
6922 await _make_repo(db_session)
6923 response = await client.get("/testuser/test-beats/view/main")
6924 assert response.status_code == 200
6925 body = response.text
6926 assert "ins-page" in body
6927
6928
6929 @pytest.mark.anyio
6930 async def test_piano_roll_page_has_token_form(
6931 client: AsyncClient,
6932 db_session: AsyncSession,
6933 ) -> None:
6934 """Insights page (aliases /view) renders the domain viewer container and page config."""
6935 await _make_repo(db_session)
6936 response = await client.get("/testuser/test-beats/view/main")
6937 assert response.status_code == 200
6938 assert "ins-page" in response.text
6939 assert "viewerType" in response.text
6940
6941
6942 @pytest.mark.anyio
6943 async def test_piano_roll_page_unknown_repo_404(
6944 client: AsyncClient,
6945 db_session: AsyncSession,
6946 ) -> None:
6947 """Piano roll page for an unknown repo returns 404."""
6948 response = await client.get("/nobody/no-repo/view/main")
6949 assert response.status_code == 404
6950
6951
6952 @pytest.mark.anyio
6953 async def test_arrange_tab_in_repo_nav(
6954 client: AsyncClient,
6955 db_session: AsyncSession,
6956 ) -> None:
6957 """Repo home page navigation includes an Insights link for the domain viewer."""
6958 await _make_repo(db_session)
6959 response = await client.get("/testuser/test-beats")
6960 assert response.status_code == 200
6961 assert "/insights/" in response.text
6962
6963
6964 @pytest.mark.anyio
6965 async def test_piano_roll_track_page_returns_200(
6966 client: AsyncClient,
6967 db_session: AsyncSession,
6968 ) -> None:
6969 """GET /view/{ref}/{path} (single track) returns 200."""
6970 await _make_repo(db_session)
6971 response = await client.get(
6972 "/testuser/test-beats/view/main/tracks/bass.mid"
6973 )
6974 assert response.status_code == 200
6975
6976
6977 @pytest.mark.anyio
6978 async def test_piano_roll_track_page_embeds_path(
6979 client: AsyncClient,
6980 db_session: AsyncSession,
6981 ) -> None:
6982 """Single-track piano roll page embeds the MIDI file path in the JS context."""
6983 await _make_repo(db_session)
6984 response = await client.get(
6985 "/testuser/test-beats/view/main/tracks/bass.mid"
6986 )
6987 assert response.status_code == 200
6988 assert "tracks/bass.mid" in response.text
6989
6990
6991 @pytest.mark.anyio
6992 async def test_piano_roll_js_served(client: AsyncClient) -> None:
6993 """GET /static/piano-roll.js returns 200 JavaScript."""
6994 response = await client.get("/static/piano-roll.js")
6995 assert response.status_code == 200
6996 assert "javascript" in response.headers.get("content-type", "")
6997
6998
6999 @pytest.mark.anyio
7000 async def test_piano_roll_js_contains_renderer(client: AsyncClient) -> None:
7001 """piano-roll.js exports the PianoRoll.render function."""
7002 response = await client.get("/static/piano-roll.js")
7003 assert response.status_code == 200
7004 body = response.text
7005 assert "PianoRoll" in body
7006 assert "render" in body
7007
7008
7009
7010 async def _seed_blob_fixtures(db_session: AsyncSession) -> str:
7011 """Seed a public repo with a branch and typed objects for blob viewer tests.
7012
7013 Creates:
7014 - repo: testuser/blob-test (public)
7015 - branch: main
7016 - objects: tracks/bass.mid, tracks/keys.mp3, metadata.json, cover.webp
7017
7018 Returns repo_id.
7019 """
7020 repo = MusehubRepo(
7021 name="blob-test",
7022 owner="testuser",
7023 slug="blob-test",
7024 visibility="public",
7025 owner_user_id="test-owner",
7026 )
7027 db_session.add(repo)
7028 await db_session.flush()
7029
7030 commit = MusehubCommit(
7031 commit_id="blobdeadbeef12",
7032 repo_id=str(repo.repo_id),
7033 message="add blob fixtures",
7034 branch="main",
7035 author="testuser",
7036 timestamp=datetime.now(tz=UTC),
7037 )
7038 db_session.add(commit)
7039
7040 branch = MusehubBranch(
7041 repo_id=str(repo.repo_id),
7042 name="main",
7043 head_commit_id="blobdeadbeef12",
7044 )
7045 db_session.add(branch)
7046
7047 for path, size in [
7048 ("tracks/bass.mid", 2048),
7049 ("tracks/keys.mp3", 8192),
7050 ("metadata.json", 512),
7051 ("cover.webp", 4096),
7052 ]:
7053 obj = MusehubObject(
7054 object_id=f"sha256:blob_{path.replace('/', '_')}",
7055 repo_id=str(repo.repo_id),
7056 path=path,
7057 size_bytes=size,
7058 disk_path=f"/tmp/blob_{path.replace('/', '_')}",
7059 )
7060 db_session.add(obj)
7061
7062 await db_session.commit()
7063 return str(repo.repo_id)
7064
7065
7066
7067 @pytest.mark.anyio
7068 async def test_blob_404_unknown_path(
7069 client: AsyncClient,
7070 db_session: AsyncSession,
7071 ) -> None:
7072 """GET /api/v1/repos/{repo_id}/blob/{ref}/{path} returns 404 for unknown path."""
7073 repo_id = await _seed_blob_fixtures(db_session)
7074 response = await client.get(f"/api/v1/repos/{repo_id}/blob/main/does/not/exist.mid")
7075 assert response.status_code == 404
7076
7077
7078 @pytest.mark.anyio
7079 async def test_blob_image_shows_inline(
7080 client: AsyncClient,
7081 db_session: AsyncSession,
7082 ) -> None:
7083 """Blob page for .webp file includes <img> rendering logic in the template JS."""
7084 await _seed_blob_fixtures(db_session)
7085 response = await client.get("/testuser/blob-test/blob/main/cover.webp")
7086 assert response.status_code == 200
7087 body = response.text
7088 # blob.ts handles image rendering client-side; SSR provides __blobCfg data
7089 assert "__blobCfg" in body
7090 assert "cover.webp" in body
7091
7092
7093 @pytest.mark.anyio
7094 async def test_blob_json_response(
7095 client: AsyncClient,
7096 db_session: AsyncSession,
7097 ) -> None:
7098 """GET /api/v1/repos/{repo_id}/blob/{ref}/{path} returns BlobMetaResponse JSON."""
7099 repo_id = await _seed_blob_fixtures(db_session)
7100 response = await client.get(
7101 f"/api/v1/repos/{repo_id}/blob/main/tracks/bass.mid"
7102 )
7103 assert response.status_code == 200
7104 data = response.json()
7105 assert data["path"] == "tracks/bass.mid"
7106 assert data["filename"] == "bass.mid"
7107 assert data["sizeBytes"] == 2048
7108 assert data["fileType"] == "midi"
7109 assert data["sha"].startswith("sha256:")
7110 assert "/raw/" in data["rawUrl"]
7111 # MIDI is binary — no content_text
7112 assert data["contentText"] is None
7113 @pytest.mark.anyio
7114 async def test_blob_json_syntax_highlighted(
7115 client: AsyncClient,
7116 db_session: AsyncSession,
7117 ) -> None:
7118 """Blob page for .json file includes syntax-highlighting logic in the template JS."""
7119 await _seed_blob_fixtures(db_session)
7120 response = await client.get("/testuser/blob-test/blob/main/metadata.json")
7121 assert response.status_code == 200
7122 body = response.text
7123 # blob.ts handles syntax highlighting client-side; SSR provides __blobCfg data
7124 assert "__blobCfg" in body
7125 assert "metadata.json" in body
7126
7127
7128 @pytest.mark.anyio
7129 async def test_blob_midi_shows_piano_roll_link(
7130 client: AsyncClient,
7131 db_session: AsyncSession,
7132 ) -> None:
7133 """GET /{owner}/{repo}/blob/{ref}/{path} returns 200 HTML for a .mid file.
7134
7135 The template's client-side JS must reference the piano roll URL pattern so that
7136 clicking the page in a browser navigates to the piano roll viewer.
7137 """
7138 await _seed_blob_fixtures(db_session)
7139 response = await client.get("/testuser/blob-test/blob/main/tracks/bass.mid")
7140 assert response.status_code == 200
7141 assert "text/html" in response.headers["content-type"]
7142 body = response.text
7143 # JS in the template constructs piano-roll URLs for MIDI files
7144 assert "piano-roll" in body or "Piano Roll" in body
7145 # Filename is embedded in the page context
7146 assert "bass.mid" in body
7147
7148
7149 @pytest.mark.anyio
7150 async def test_blob_mp3_shows_audio_player(
7151 client: AsyncClient,
7152 db_session: AsyncSession,
7153 ) -> None:
7154 """Blob page for .mp3 file includes <audio> rendering logic in the template JS."""
7155 await _seed_blob_fixtures(db_session)
7156 response = await client.get("/testuser/blob-test/blob/main/tracks/keys.mp3")
7157 assert response.status_code == 200
7158 body = response.text
7159 # JS template emits <audio> element for audio file type
7160 assert "<audio" in body or "blob-audio" in body
7161 assert "keys.mp3" in body
7162
7163
7164 @pytest.mark.anyio
7165 async def test_blob_raw_button(
7166 client: AsyncClient,
7167 db_session: AsyncSession,
7168 ) -> None:
7169 """Blob page JS constructs a Raw download link via the /raw/ endpoint."""
7170 await _seed_blob_fixtures(db_session)
7171 response = await client.get("/testuser/blob-test/blob/main/tracks/bass.mid")
7172 assert response.status_code == 200
7173 body = response.text
7174 # JS constructs raw URL — the string '/raw/' must appear in the template script
7175 assert "/raw/" in body
7176
7177
7178 @pytest.mark.anyio
7179 async def test_score_page_contains_legend(
7180 client: AsyncClient,
7181 db_session: AsyncSession,
7182 ) -> None:
7183 """Score page includes a legend for note symbols."""
7184 await _make_repo(db_session)
7185 response = await client.get("/testuser/test-beats/score/main")
7186 assert response.status_code == 200
7187 body = response.text
7188 assert "legend" in body or "Note" in body
7189
7190
7191 @pytest.mark.anyio
7192 async def test_score_page_contains_score_meta(
7193 client: AsyncClient,
7194 db_session: AsyncSession,
7195 ) -> None:
7196 """Score page embeds a score metadata panel (key/tempo/time signature)."""
7197 await _make_repo(db_session)
7198 response = await client.get("/testuser/test-beats/score/main")
7199 assert response.status_code == 200
7200 body = response.text
7201 assert "score-meta" in body
7202
7203
7204 @pytest.mark.anyio
7205 async def test_score_page_contains_staff_container(
7206 client: AsyncClient,
7207 db_session: AsyncSession,
7208 ) -> None:
7209 """Score page embeds the SVG staff container markup."""
7210 await _make_repo(db_session)
7211 response = await client.get("/testuser/test-beats/score/main")
7212 assert response.status_code == 200
7213 body = response.text
7214 assert "staff-container" in body or "staves" in body
7215
7216
7217 @pytest.mark.anyio
7218 async def test_score_page_contains_track_selector(
7219 client: AsyncClient,
7220 db_session: AsyncSession,
7221 ) -> None:
7222 """Score page embeds a track selector element."""
7223 await _make_repo(db_session)
7224 response = await client.get("/testuser/test-beats/score/main")
7225 assert response.status_code == 200
7226 body = response.text
7227 assert "track-selector" in body
7228
7229
7230 @pytest.mark.anyio
7231 async def test_score_page_no_auth_required(
7232 client: AsyncClient,
7233 db_session: AsyncSession,
7234 ) -> None:
7235 """Score UI page must be accessible without an Authorization header."""
7236 await _make_repo(db_session)
7237 response = await client.get("/testuser/test-beats/score/main")
7238 assert response.status_code == 200
7239 assert response.status_code != 401
7240
7241
7242 @pytest.mark.anyio
7243 async def test_score_page_renders(
7244 client: AsyncClient,
7245 db_session: AsyncSession,
7246 ) -> None:
7247 """GET /{owner}/{slug}/score/{ref} returns 200 HTML."""
7248 await _make_repo(db_session)
7249 response = await client.get("/testuser/test-beats/score/main")
7250 assert response.status_code == 200
7251 assert "text/html" in response.headers["content-type"]
7252 body = response.text
7253 assert "MuseHub" in body
7254
7255
7256 @pytest.mark.anyio
7257 async def test_score_part_page_includes_path(
7258 client: AsyncClient,
7259 db_session: AsyncSession,
7260 ) -> None:
7261 """Single-part score page injects the path segment into page data."""
7262 await _make_repo(db_session)
7263 response = await client.get("/testuser/test-beats/score/main/piano")
7264 assert response.status_code == 200
7265 body = response.text
7266 # scorePath JS variable should be set to the path segment
7267 assert "piano" in body
7268
7269
7270 @pytest.mark.anyio
7271 async def test_score_part_page_renders(
7272 client: AsyncClient,
7273 db_session: AsyncSession,
7274 ) -> None:
7275 """GET /{owner}/{slug}/score/{ref}/{path} returns 200 HTML."""
7276 await _make_repo(db_session)
7277 response = await client.get("/testuser/test-beats/score/main/piano")
7278 assert response.status_code == 200
7279 assert "text/html" in response.headers["content-type"]
7280 body = response.text
7281 assert "MuseHub" in body
7282
7283
7284 @pytest.mark.anyio
7285 async def test_score_unknown_repo_404(
7286 client: AsyncClient,
7287 db_session: AsyncSession,
7288 ) -> None:
7289 """GET /{unknown}/{slug}/score/{ref} returns 404."""
7290 response = await client.get("/nobody/no-beats/score/main")
7291 assert response.status_code == 404
7292
7293
7294 # ---------------------------------------------------------------------------
7295 # Arrangement matrix page — # ---------------------------------------------------------------------------
7296
7297
7298 # ---------------------------------------------------------------------------
7299 # Piano roll page tests — # ---------------------------------------------------------------------------
7300
7301
7302 @pytest.mark.anyio
7303 async def test_ui_commit_page_artifact_auth_uses_blob_proxy(
7304 client: AsyncClient,
7305 db_session: AsyncSession,
7306 ) -> None:
7307 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7308
7309 The pre-SSR blob-proxy artifact pattern no longer applies — artifacts are loaded
7310 via the API. Non-existent commit SHAs now return 404 rather than an empty JS shell.
7311 """
7312 await _make_repo(db_session)
7313 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7314 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
7315 assert response.status_code == 404
7316
7317
7318 # ---------------------------------------------------------------------------
7319 # Reaction bars — # ---------------------------------------------------------------------------
7320
7321
7322 @pytest.mark.anyio
7323 async def test_reaction_bar_js_in_musehub_js(
7324 client: AsyncClient,
7325 db_session: AsyncSession,
7326 ) -> None:
7327 """musehub.js must define loadReactions and toggleReaction for all detail pages."""
7328 response = await client.get("/static/musehub.js")
7329 assert response.status_code == 200
7330 body = response.text
7331 assert "loadReactions" in body
7332 assert "toggleReaction" in body
7333 assert "REACTION_BAR_EMOJIS" in body
7334
7335
7336 @pytest.mark.anyio
7337 async def test_reaction_bar_emojis_in_musehub_js(
7338 client: AsyncClient,
7339 db_session: AsyncSession,
7340 ) -> None:
7341 """musehub.js reaction bar must include all 8 required emojis."""
7342 response = await client.get("/static/musehub.js")
7343 assert response.status_code == 200
7344 body = response.text
7345 for emoji in ["🔥", "❤️", "👏", "✨", "🎵", "🎸", "🎹", "🥁"]:
7346 assert emoji in body, f"Emoji {emoji!r} missing from musehub.js"
7347
7348
7349 @pytest.mark.anyio
7350 async def test_reaction_bar_commit_page_has_load_call(
7351 client: AsyncClient,
7352 db_session: AsyncSession,
7353 ) -> None:
7354 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
7355
7356 Reactions are loaded via the API; the reaction bar is no longer a JS-only element
7357 in the SSR commit_detail.html template. Non-existent commits return 404.
7358 """
7359 await _make_repo(db_session)
7360 commit_id = "abc1234567890abcdef1234567890abcdef12345678"
7361 response = await client.get(f"/testuser/test-beats/commits/{commit_id}")
7362 assert response.status_code == 404
7363
7364
7365 @pytest.mark.anyio
7366 async def test_reaction_bar_pr_detail_has_load_call(
7367 client: AsyncClient,
7368 db_session: AsyncSession,
7369 ) -> None:
7370 """PR detail page renders SSR pull request content."""
7371 from musehub.db.musehub_models import MusehubPullRequest
7372 repo_id = await _make_repo(db_session)
7373 pr = MusehubPullRequest(
7374 repo_id=repo_id,
7375 title="Test PR for reaction bar",
7376 body="",
7377 state="open",
7378 from_branch="feat/test",
7379 to_branch="main",
7380 author="testuser",
7381 )
7382 db_session.add(pr)
7383 await db_session.commit()
7384 await db_session.refresh(pr)
7385 pr_id = str(pr.pr_id)
7386
7387 response = await client.get(f"/testuser/test-beats/pulls/{pr_id}")
7388 assert response.status_code == 200
7389 body = response.text
7390 assert "pd-layout" in body
7391 assert pr_id[:8] in body
7392
7393
7394 @pytest.mark.anyio
7395 async def test_reaction_bar_issue_detail_has_load_call(
7396 client: AsyncClient,
7397 db_session: AsyncSession,
7398 ) -> None:
7399 """Issue detail page renders SSR issue content."""
7400 from musehub.db.musehub_models import MusehubIssue
7401 repo_id = await _make_repo(db_session)
7402 issue = MusehubIssue(
7403 repo_id=repo_id,
7404 number=1,
7405 title="Test issue for reaction bar",
7406 body="",
7407 state="open",
7408 labels=[],
7409 author="testuser",
7410 )
7411 db_session.add(issue)
7412 await db_session.commit()
7413
7414 response = await client.get("/testuser/test-beats/issues/1")
7415 assert response.status_code == 200
7416 body = response.text
7417 assert "id-layout" in body
7418 assert "Test issue for reaction bar" in body
7419
7420
7421 @pytest.mark.anyio
7422 async def test_reaction_bar_release_detail_has_load_call(
7423 client: AsyncClient,
7424 db_session: AsyncSession,
7425 ) -> None:
7426 """Release detail page renders SSR release content (includes loadReactions call)."""
7427 repo_id = await _make_repo(db_session)
7428 release = MusehubRelease(
7429 repo_id=repo_id,
7430 tag="v1.0",
7431 title="Test Release v1.0",
7432 body="Initial release notes.",
7433 author="testuser",
7434 )
7435 db_session.add(release)
7436 await db_session.commit()
7437
7438 response = await client.get("/testuser/test-beats/releases/v1.0")
7439 assert response.status_code == 200
7440 body = response.text
7441 assert "v1.0" in body
7442 assert "Test Release v1.0" in body
7443 assert "rd-header" in body
7444 assert '"page": "release-detail"' in body
7445
7446
7447 @pytest.mark.anyio
7448 async def test_reaction_bar_session_detail_has_load_call(
7449 client: AsyncClient,
7450 db_session: AsyncSession,
7451 ) -> None:
7452 """Session detail page renders SSR session content."""
7453 repo_id = await _make_repo(db_session)
7454 session_id = await _make_session(db_session, repo_id)
7455
7456 response = await client.get(f"/testuser/test-beats/sessions/{session_id}")
7457 assert response.status_code == 200
7458 body = response.text
7459 assert "Session" in body
7460 assert session_id[:8] in body
7461
7462
7463 @pytest.mark.anyio
7464 async def test_reaction_api_allows_new_emojis(
7465 client: AsyncClient,
7466 db_session: AsyncSession,
7467 ) -> None:
7468 """POST /reactions with 👏 and 🎹 (new emojis) must be accepted (not 400)."""
7469 from musehub.db.musehub_models import MusehubRepo
7470 repo = MusehubRepo(
7471 name="reaction-test",
7472 owner="testuser",
7473 slug="reaction-test",
7474 visibility="public",
7475 owner_user_id="reaction-owner",
7476 )
7477 db_session.add(repo)
7478 await db_session.commit()
7479 await db_session.refresh(repo)
7480 repo_id = str(repo.repo_id)
7481
7482 token_headers = {"Authorization": "Bearer test-token"}
7483
7484 for emoji in ["👏", "🎹"]:
7485 response = await client.post(
7486 f"/api/v1/repos/{repo_id}/reactions",
7487 json={"target_type": "commit", "target_id": "abc123", "emoji": emoji},
7488 headers=token_headers,
7489 )
7490 assert response.status_code not in (400, 422), (
7491 f"Emoji {emoji!r} rejected by API: {response.status_code} {response.text}"
7492 )
7493
7494
7495 @pytest.mark.anyio
7496 async def test_reaction_api_allows_release_and_session_target_types(
7497 client: AsyncClient,
7498 db_session: AsyncSession,
7499 ) -> None:
7500 """POST /reactions must accept 'release' and 'session' as target_type values.
7501
7502 These target types were added to support reaction bars on
7503 release_detail and session_detail pages.
7504 """
7505 from musehub.db.musehub_models import MusehubRepo
7506 repo = MusehubRepo(
7507 name="target-type-test",
7508 owner="testuser",
7509 slug="target-type-test",
7510 visibility="public",
7511 owner_user_id="target-type-owner",
7512 )
7513 db_session.add(repo)
7514 await db_session.commit()
7515 await db_session.refresh(repo)
7516 repo_id = str(repo.repo_id)
7517
7518 token_headers = {"Authorization": "Bearer test-token"}
7519
7520 for target_type in ["release", "session"]:
7521 response = await client.post(
7522 f"/api/v1/repos/{repo_id}/reactions",
7523 json={"target_type": target_type, "target_id": "some-id", "emoji": "🔥"},
7524 headers=token_headers,
7525 )
7526 assert response.status_code not in (400, 422), (
7527 f"target_type {target_type!r} rejected: {response.status_code} {response.text}"
7528 )
7529
7530
7531 @pytest.mark.anyio
7532 async def test_reaction_bar_css_loaded_on_detail_pages(
7533 client: AsyncClient,
7534 db_session: AsyncSession,
7535 ) -> None:
7536 """Detail pages return 200 and load app.css (base stylesheet)."""
7537 from musehub.db.musehub_models import MusehubIssue, MusehubPullRequest
7538 repo_id = await _make_repo(db_session)
7539
7540 pr = MusehubPullRequest(
7541 repo_id=repo_id,
7542 title="CSS test PR",
7543 body="",
7544 state="open",
7545 from_branch="feat/css",
7546 to_branch="main",
7547 author="testuser",
7548 )
7549 db_session.add(pr)
7550 issue = MusehubIssue(
7551 repo_id=repo_id,
7552 number=1,
7553 title="CSS test issue",
7554 body="",
7555 state="open",
7556 labels=[],
7557 author="testuser",
7558 )
7559 db_session.add(issue)
7560 release = MusehubRelease(
7561 repo_id=repo_id,
7562 tag="v1.0",
7563 title="CSS test release",
7564 body="",
7565 author="testuser",
7566 )
7567 db_session.add(release)
7568 await db_session.commit()
7569 await db_session.refresh(pr)
7570 pr_id = str(pr.pr_id)
7571 session_id = await _make_session(db_session, repo_id)
7572
7573 pages = [
7574 f"/testuser/test-beats/pulls/{pr_id}",
7575 "/testuser/test-beats/issues/1",
7576 "/testuser/test-beats/releases/v1.0",
7577 f"/testuser/test-beats/sessions/{session_id}",
7578 ]
7579 for page in pages:
7580 response = await client.get(page)
7581 assert response.status_code == 200, f"Expected 200 for {page}, got {response.status_code}"
7582 assert "app.css" in response.text, f"app.css missing from {page}"
7583
7584
7585 @pytest.mark.anyio
7586 async def test_reaction_bar_components_css_has_styles(
7587 client: AsyncClient,
7588 db_session: AsyncSession,
7589 ) -> None:
7590 """app.css must define .reaction-bar and .reaction-btn CSS classes."""
7591 response = await client.get("/static/app.css")
7592 assert response.status_code == 200
7593 body = response.text
7594 assert ".reaction-bar" in body
7595 assert ".reaction-btn" in body
7596 assert ".reaction-btn--active" in body
7597 assert ".reaction-count" in body
7598
7599
7600 # ---------------------------------------------------------------------------
7601 # Feed page tests — (rich event cards)
7602 # ---------------------------------------------------------------------------
7603
7604
7605 @pytest.mark.anyio
7606 async def test_feed_page_returns_200(
7607 client: AsyncClient,
7608 db_session: AsyncSession,
7609 ) -> None:
7610 """GET /feed returns 200 HTML without requiring a JWT."""
7611 response = await client.get("/feed")
7612 assert response.status_code == 200
7613 assert "text/html" in response.headers["content-type"]
7614 assert "Activity Feed" in response.text
7615
7616
7617 @pytest.mark.anyio
7618 async def test_feed_page_no_raw_json_payload(
7619 client: AsyncClient,
7620 db_session: AsyncSession,
7621 ) -> None:
7622 """Feed page must not render raw JSON.stringify of notification payload.
7623
7624 Regression guard: the old implementation called
7625 JSON.stringify(item.payload) directly into the DOM, exposing raw JSON
7626 to users. The new rich card templates must not do this.
7627 """
7628 response = await client.get("/feed")
7629 assert response.status_code == 200
7630 body = response.text
7631 assert "JSON.stringify(item.payload" not in body
7632 assert "JSON.stringify(item" not in body
7633
7634
7635 @pytest.mark.anyio
7636 async def test_feed_page_has_event_meta_for_all_types(
7637 client: AsyncClient,
7638 db_session: AsyncSession,
7639 ) -> None:
7640 """Feed page dispatches feed.ts which handles all 8 notification event types."""
7641 response = await client.get("/feed")
7642 assert response.status_code == 200
7643 body = response.text
7644 assert '"page": "feed"' in body
7645
7646
7647 @pytest.mark.anyio
7648 async def test_feed_page_has_data_notif_id_attribute(
7649 client: AsyncClient,
7650 db_session: AsyncSession,
7651 ) -> None:
7652 """Feed page renders via feed.ts; data-notif-id attached client-side."""
7653 response = await client.get("/feed")
7654 assert response.status_code == 200
7655 assert '"page": "feed"' in response.text
7656
7657
7658 @pytest.mark.anyio
7659 async def test_feed_page_has_unread_indicator(
7660 client: AsyncClient,
7661 db_session: AsyncSession,
7662 ) -> None:
7663 """Feed page dispatches feed.ts which highlights unread cards client-side."""
7664 response = await client.get("/feed")
7665 assert response.status_code == 200
7666 body = response.text
7667 assert '"page": "feed"' in body
7668
7669
7670 @pytest.mark.anyio
7671 async def test_feed_page_has_actor_avatar_logic(
7672 client: AsyncClient,
7673 db_session: AsyncSession,
7674 ) -> None:
7675 """Feed page dispatches feed.ts; actorHsl / actorAvatar helpers live in that module."""
7676 response = await client.get("/feed")
7677 assert response.status_code == 200
7678 assert '"page": "feed"' in response.text
7679
7680
7681 @pytest.mark.anyio
7682 async def test_feed_page_has_relative_timestamp(
7683 client: AsyncClient,
7684 db_session: AsyncSession,
7685 ) -> None:
7686 """Feed page dispatches feed.ts; fmtRelative called client-side by that module."""
7687 response = await client.get("/feed")
7688 assert response.status_code == 200
7689 assert '"page": "feed"' in response.text
7690
7691
7692 # ---------------------------------------------------------------------------
7693 # Mark-as-read UX tests — # ---------------------------------------------------------------------------
7694
7695
7696 @pytest.mark.anyio
7697 async def test_feed_page_has_mark_one_read_function(
7698 client: AsyncClient,
7699 db_session: AsyncSession,
7700 ) -> None:
7701 """Feed page dispatches feed.ts; markOneRead() lives in that module."""
7702 response = await client.get("/feed")
7703 assert response.status_code == 200
7704 assert '"page": "feed"' in response.text
7705
7706
7707 @pytest.mark.anyio
7708 async def test_feed_page_has_mark_all_read_function(
7709 client: AsyncClient,
7710 db_session: AsyncSession,
7711 ) -> None:
7712 """Feed page dispatches feed.ts; markAllRead() lives in that module."""
7713 response = await client.get("/feed")
7714 assert response.status_code == 200
7715 assert '"page": "feed"' in response.text
7716
7717
7718 @pytest.mark.anyio
7719 async def test_feed_page_has_decrement_nav_badge_function(
7720 client: AsyncClient,
7721 db_session: AsyncSession,
7722 ) -> None:
7723 """Feed page dispatches feed.ts; decrementNavBadge() lives in that module."""
7724 response = await client.get("/feed")
7725 assert response.status_code == 200
7726 assert '"page": "feed"' in response.text
7727
7728
7729 @pytest.mark.anyio
7730 async def test_feed_page_mark_read_btn_targets_notification_endpoint(
7731 client: AsyncClient,
7732 db_session: AsyncSession,
7733 ) -> None:
7734 """Feed page dispatches feed.ts; mark-read calls handled client-side by that module."""
7735 response = await client.get("/feed")
7736 assert response.status_code == 200
7737 assert '"page": "feed"' in response.text
7738
7739
7740 @pytest.mark.anyio
7741 async def test_feed_page_mark_all_btn_targets_read_all_endpoint(
7742 client: AsyncClient,
7743 db_session: AsyncSession,
7744 ) -> None:
7745 """Feed page dispatches feed.ts; read-all endpoint called client-side by that module."""
7746 response = await client.get("/feed")
7747 assert response.status_code == 200
7748 assert '"page": "feed"' in response.text
7749
7750
7751 @pytest.mark.anyio
7752 async def test_feed_page_mark_all_btn_present_in_template(
7753 client: AsyncClient,
7754 db_session: AsyncSession,
7755 ) -> None:
7756 """Feed page dispatches feed.ts; mark-all-read button rendered client-side."""
7757 response = await client.get("/feed")
7758 assert response.status_code == 200
7759 assert '"page": "feed"' in response.text
7760
7761
7762 @pytest.mark.anyio
7763 async def test_feed_page_mark_read_updates_nav_badge(
7764 client: AsyncClient,
7765 db_session: AsyncSession,
7766 ) -> None:
7767 """Feed page dispatches feed.ts; nav-notif-badge updated client-side by that module."""
7768 response = await client.get("/feed")
7769 assert response.status_code == 200
7770 assert '"page": "feed"' in response.text
7771
7772
7773 # ---------------------------------------------------------------------------
7774 # Per-dimension analysis detail pages
7775 # ---------------------------------------------------------------------------
7776
7777
7778 @pytest.mark.anyio
7779 async def test_key_analysis_page_renders(
7780 client: AsyncClient,
7781 db_session: AsyncSession,
7782 ) -> None:
7783 """GET /{owner}/{repo_slug}/insights/{ref}/key returns 200 HTML."""
7784 await _make_repo(db_session)
7785 ref = "abc1234567890abcdef"
7786 response = await client.get(f"/testuser/test-beats/insights/{ref}/key")
7787 assert response.status_code == 200
7788 assert "text/html" in response.headers["content-type"]
7789
7790
7791 @pytest.mark.anyio
7792 async def test_key_analysis_page_no_auth_required(
7793 client: AsyncClient,
7794 db_session: AsyncSession,
7795 ) -> None:
7796 """Key analysis page must be accessible without a JWT (HTML shell handles auth)."""
7797 await _make_repo(db_session)
7798 ref = "deadbeef1234"
7799 response = await client.get(f"/testuser/test-beats/insights/{ref}/key")
7800 assert response.status_code != 401
7801 assert response.status_code == 200
7802
7803
7804 @pytest.mark.anyio
7805 async def test_key_analysis_page_contains_key_data_labels(
7806 client: AsyncClient,
7807 db_session: AsyncSession,
7808 ) -> None:
7809 """Key page must contain tonic, mode, relative key, and confidence UI elements."""
7810 await _make_repo(db_session)
7811 ref = "cafebabe12345678"
7812 response = await client.get(f"/testuser/test-beats/insights/{ref}/key")
7813 assert response.status_code == 200
7814 body = response.text
7815 assert "Key Detection" in body
7816 assert "Relative Key" in body
7817 assert "Detection Confidence" in body
7818 assert "Alternate Key" in body
7819
7820
7821 @pytest.mark.anyio
7822 async def test_meter_analysis_page_renders(
7823 client: AsyncClient,
7824 db_session: AsyncSession,
7825 ) -> None:
7826 """GET /{owner}/{repo_slug}/insights/{ref}/meter returns 200 HTML."""
7827 await _make_repo(db_session)
7828 ref = "abc1234567890abcdef"
7829 response = await client.get(f"/testuser/test-beats/insights/{ref}/meter")
7830 assert response.status_code == 200
7831 assert "text/html" in response.headers["content-type"]
7832
7833
7834 @pytest.mark.anyio
7835 async def test_meter_analysis_page_no_auth_required(
7836 client: AsyncClient,
7837 db_session: AsyncSession,
7838 ) -> None:
7839 """Meter analysis page must be accessible without a JWT (HTML shell handles auth)."""
7840 await _make_repo(db_session)
7841 ref = "deadbeef5678"
7842 response = await client.get(f"/testuser/test-beats/insights/{ref}/meter")
7843 assert response.status_code != 401
7844 assert response.status_code == 200
7845
7846
7847 @pytest.mark.anyio
7848 async def test_meter_analysis_page_contains_meter_data_labels(
7849 client: AsyncClient,
7850 db_session: AsyncSession,
7851 ) -> None:
7852 """Meter page must contain time signature, compound/simple badge, and beat strength UI."""
7853 await _make_repo(db_session)
7854 ref = "feedface5678"
7855 response = await client.get(f"/testuser/test-beats/insights/{ref}/meter")
7856 assert response.status_code == 200
7857 body = response.text
7858 assert "Meter Analysis" in body
7859 assert "Time Signature" in body
7860 assert "Beat Strength Profile" in body
7861 # SSR migration (issue #578): beat strength is now rendered as inline CSS bars,
7862 # not as a JS function call. Verify the label is present and CSS bars are rendered.
7863 assert "border-radius" in body or "%" in body
7864
7865
7866 @pytest.mark.anyio
7867 async def test_chord_map_analysis_page_renders(
7868 client: AsyncClient,
7869 db_session: AsyncSession,
7870 ) -> None:
7871 """GET /{owner}/{repo_slug}/insights/{ref}/chord-map returns 200 HTML."""
7872 await _make_repo(db_session)
7873 ref = "abc1234567890abcdef"
7874 response = await client.get(f"/testuser/test-beats/insights/{ref}/chord-map")
7875 assert response.status_code == 200
7876 assert "text/html" in response.headers["content-type"]
7877
7878
7879 @pytest.mark.anyio
7880 async def test_chord_map_analysis_page_no_auth_required(
7881 client: AsyncClient,
7882 db_session: AsyncSession,
7883 ) -> None:
7884 """Chord-map analysis page must be accessible without a JWT (HTML shell handles auth)."""
7885 await _make_repo(db_session)
7886 ref = "deadbeef9999"
7887 response = await client.get(f"/testuser/test-beats/insights/{ref}/chord-map")
7888 assert response.status_code != 401
7889 assert response.status_code == 200
7890
7891
7892 @pytest.mark.anyio
7893 async def test_chord_map_analysis_page_contains_chord_data_labels(
7894 client: AsyncClient,
7895 db_session: AsyncSession,
7896 ) -> None:
7897 """Chord-map page SSR: must contain progression timeline, chord table, and tension data."""
7898 await _make_repo(db_session)
7899 ref = "beefdead1234"
7900 response = await client.get(f"/testuser/test-beats/insights/{ref}/chord-map")
7901 assert response.status_code == 200
7902 body = response.text
7903 assert "Chord Map" in body
7904 assert "PROGRESSION TIMELINE" in body
7905 assert "CHORD TABLE" in body
7906 assert "tension" in body.lower()
7907
7908
7909 @pytest.mark.anyio
7910 async def test_groove_analysis_page_renders(
7911 client: AsyncClient,
7912 db_session: AsyncSession,
7913 ) -> None:
7914 """GET /{owner}/{repo_slug}/insights/{ref}/groove returns 200 HTML."""
7915 await _make_repo(db_session)
7916 ref = "abc1234567890abcdef"
7917 response = await client.get(f"/testuser/test-beats/insights/{ref}/groove")
7918 assert response.status_code == 200
7919 assert "text/html" in response.headers["content-type"]
7920
7921
7922 @pytest.mark.anyio
7923 async def test_groove_analysis_page_no_auth_required(
7924 client: AsyncClient,
7925 db_session: AsyncSession,
7926 ) -> None:
7927 """Groove analysis page must be accessible without a JWT (HTML shell handles auth)."""
7928 await _make_repo(db_session)
7929 ref = "deadbeef4321"
7930 response = await client.get(f"/testuser/test-beats/insights/{ref}/groove")
7931 assert response.status_code != 401
7932 assert response.status_code == 200
7933
7934
7935 @pytest.mark.anyio
7936 async def test_groove_analysis_page_contains_groove_data_labels(
7937 client: AsyncClient,
7938 db_session: AsyncSession,
7939 ) -> None:
7940 """Groove page must contain style badge, BPM, swing factor, and groove score UI."""
7941 await _make_repo(db_session)
7942 ref = "cafefeed5678"
7943 response = await client.get(f"/testuser/test-beats/insights/{ref}/groove")
7944 assert response.status_code == 200
7945 body = response.text
7946 assert "Groove Analysis" in body
7947 assert "Style" in body
7948 assert "BPM" in body
7949 assert "Groove Score" in body
7950 assert "Swing Factor" in body
7951
7952
7953 @pytest.mark.anyio
7954 async def test_emotion_analysis_page_renders(
7955 client: AsyncClient,
7956 db_session: AsyncSession,
7957 ) -> None:
7958 """GET /{owner}/{repo_slug}/insights/{ref}/emotion returns 200 HTML."""
7959 await _make_repo(db_session)
7960 ref = "abc1234567890abcdef"
7961 response = await client.get(f"/testuser/test-beats/insights/{ref}/emotion")
7962 assert response.status_code == 200
7963 assert "text/html" in response.headers["content-type"]
7964
7965
7966 @pytest.mark.anyio
7967 async def test_emotion_analysis_page_no_auth_required(
7968 client: AsyncClient,
7969 db_session: AsyncSession,
7970 ) -> None:
7971 """Emotion analysis page must be accessible without a JWT (HTML shell handles auth)."""
7972 await _make_repo(db_session)
7973 ref = "deadbeef0001"
7974 response = await client.get(f"/testuser/test-beats/insights/{ref}/emotion")
7975 assert response.status_code != 401
7976 assert response.status_code == 200
7977
7978
7979 @pytest.mark.anyio
7980 async def test_emotion_analysis_page_contains_emotion_data_labels(
7981 client: AsyncClient,
7982 db_session: AsyncSession,
7983 ) -> None:
7984 """Emotion page SSR: must contain SVG scatter plot and summary vector dimension bars."""
7985 await _make_repo(db_session)
7986 ref = "aabbccdd5678"
7987 response = await client.get(f"/testuser/test-beats/insights/{ref}/emotion")
7988 assert response.status_code == 200
7989 body = response.text
7990 assert "Emotion Analysis" in body
7991 assert "SUMMARY VECTOR" in body
7992 assert "Valence" in body or "valence" in body
7993 assert "Tension" in body or "tension" in body
7994 assert "<circle" in body or "<svg" in body
7995
7996
7997 @pytest.mark.anyio
7998 async def test_form_analysis_page_renders(
7999 client: AsyncClient,
8000 db_session: AsyncSession,
8001 ) -> None:
8002 """GET /{owner}/{repo_slug}/insights/{ref}/form returns 200 HTML."""
8003 await _make_repo(db_session)
8004 ref = "abc1234567890abcdef"
8005 response = await client.get(f"/testuser/test-beats/insights/{ref}/form")
8006 assert response.status_code == 200
8007 assert "text/html" in response.headers["content-type"]
8008
8009
8010 @pytest.mark.anyio
8011 async def test_form_analysis_page_no_auth_required(
8012 client: AsyncClient,
8013 db_session: AsyncSession,
8014 ) -> None:
8015 """Form analysis page must be accessible without a JWT (HTML shell handles auth)."""
8016 await _make_repo(db_session)
8017 ref = "deadbeef0002"
8018 response = await client.get(f"/testuser/test-beats/insights/{ref}/form")
8019 assert response.status_code != 401
8020 assert response.status_code == 200
8021
8022
8023 @pytest.mark.anyio
8024 async def test_form_analysis_page_contains_form_data_labels(
8025 client: AsyncClient,
8026 db_session: AsyncSession,
8027 ) -> None:
8028 """Form page must contain form label, section timeline, and sections table."""
8029 await _make_repo(db_session)
8030 ref = "11223344abcd"
8031 response = await client.get(f"/testuser/test-beats/insights/{ref}/form")
8032 assert response.status_code == 200
8033 body = response.text
8034 assert "Form Analysis" in body
8035 assert "Form Timeline" in body or "formLabel" in body
8036 assert "Sections" in body
8037 assert "Total Beats" in body
8038
8039
8040 # ---------------------------------------------------------------------------
8041 # Issue #295 — Profile page: followers/following lists with user cards
8042 # ---------------------------------------------------------------------------
8043
8044 # test_profile_page_has_followers_following_tabs
8045 # test_profile_page_has_user_card_js
8046 # test_profile_page_has_switch_tab_js
8047 # test_followers_list_endpoint_returns_200
8048 # test_followers_list_returns_user_cards_for_known_user
8049 # test_following_list_returns_user_cards_for_known_user
8050 # test_followers_list_unknown_user_404
8051 # test_following_list_unknown_user_404
8052 # test_followers_response_includes_following_count
8053 # test_followers_list_empty_for_user_with_no_followers
8054
8055
8056 async def _make_follow(
8057 db_session: AsyncSession,
8058 follower_id: str,
8059 followee_id: str,
8060 ) -> MusehubFollow:
8061 """Seed a follow relationship and return the ORM row."""
8062 import uuid
8063 row = MusehubFollow(
8064 follow_id=str(uuid.uuid4()),
8065 follower_id=follower_id,
8066 followee_id=followee_id,
8067 )
8068 db_session.add(row)
8069 await db_session.commit()
8070 return row
8071
8072
8073 @pytest.mark.anyio
8074 async def test_profile_page_has_followers_following_tabs(
8075 client: AsyncClient,
8076 db_session: AsyncSession,
8077 ) -> None:
8078 """Profile page renders Followers and Following tab buttons."""
8079 await _make_profile(db_session, username="tabuser")
8080 response = await client.get("/tabuser")
8081 assert response.status_code == 200
8082 body = response.text
8083 assert 'data-tab="followers"' in body
8084 assert 'data-tab="following"' in body
8085
8086
8087
8088
8089 @pytest.mark.anyio
8090 async def test_profile_page_has_switch_tab_js(
8091 client: AsyncClient,
8092 db_session: AsyncSession,
8093 ) -> None:
8094 """Profile page must include switchTab() to toggle between followers and following."""
8095 await _make_profile(db_session, username="switchtabuser")
8096 response = await client.get("/switchtabuser")
8097 assert response.status_code == 200
8098 # switchTab moved to app.js TypeScript module; check page dispatch and tab structure
8099 assert '"page": "user-profile"' in response.text
8100 assert "tab-btn" in response.text
8101
8102
8103 @pytest.mark.anyio
8104 async def test_followers_list_endpoint_returns_200(
8105 client: AsyncClient,
8106 db_session: AsyncSession,
8107 ) -> None:
8108 """GET /api/v1/users/{username}/followers-list returns 200 for known user."""
8109 await _make_profile(db_session, username="followerlistuser")
8110 response = await client.get("/api/v1/users/followerlistuser/followers-list")
8111 assert response.status_code == 200
8112 assert isinstance(response.json(), list)
8113
8114
8115 @pytest.mark.anyio
8116 async def test_followers_list_returns_user_cards_for_known_user(
8117 client: AsyncClient,
8118 db_session: AsyncSession,
8119 ) -> None:
8120 """followers-list returns UserCard objects when followers exist."""
8121 import uuid
8122
8123 target = MusehubProfile(
8124 user_id="target-user-fl-01",
8125 username="flctarget",
8126 bio="I am the target",
8127 avatar_url=None,
8128 pinned_repo_ids=[],
8129 )
8130 follower = MusehubProfile(
8131 user_id="follower-user-fl-01",
8132 username="flcfollower",
8133 bio="I am a follower",
8134 avatar_url=None,
8135 pinned_repo_ids=[],
8136 )
8137 db_session.add(target)
8138 db_session.add(follower)
8139 await db_session.flush()
8140 # Seed a follow row using user_ids (same convention as the seed script)
8141 await _make_follow(db_session, follower_id="follower-user-fl-01", followee_id="target-user-fl-01")
8142
8143 response = await client.get("/api/v1/users/flctarget/followers-list")
8144 assert response.status_code == 200
8145 cards = response.json()
8146 assert len(cards) >= 1
8147 usernames = [c["username"] for c in cards]
8148 assert "flcfollower" in usernames
8149
8150
8151 @pytest.mark.anyio
8152 async def test_following_list_returns_user_cards_for_known_user(
8153 client: AsyncClient,
8154 db_session: AsyncSession,
8155 ) -> None:
8156 """following-list returns UserCard objects for users that the target follows."""
8157 actor = MusehubProfile(
8158 user_id="actor-user-fl-02",
8159 username="flcactor",
8160 bio="I follow people",
8161 avatar_url=None,
8162 pinned_repo_ids=[],
8163 )
8164 followee = MusehubProfile(
8165 user_id="followee-user-fl-02",
8166 username="flcfollowee",
8167 bio="I am followed",
8168 avatar_url=None,
8169 pinned_repo_ids=[],
8170 )
8171 db_session.add(actor)
8172 db_session.add(followee)
8173 await db_session.flush()
8174 await _make_follow(db_session, follower_id="actor-user-fl-02", followee_id="followee-user-fl-02")
8175
8176 response = await client.get("/api/v1/users/flcactor/following-list")
8177 assert response.status_code == 200
8178 cards = response.json()
8179 assert len(cards) >= 1
8180 usernames = [c["username"] for c in cards]
8181 assert "flcfollowee" in usernames
8182
8183
8184 @pytest.mark.anyio
8185 async def test_followers_list_unknown_user_404(
8186 client: AsyncClient,
8187 db_session: AsyncSession,
8188 ) -> None:
8189 """followers-list returns 404 when the target username does not exist."""
8190 response = await client.get("/api/v1/users/nonexistent-ghost-user/followers-list")
8191 assert response.status_code == 404
8192
8193
8194 @pytest.mark.anyio
8195 async def test_following_list_unknown_user_404(
8196 client: AsyncClient,
8197 db_session: AsyncSession,
8198 ) -> None:
8199 """following-list returns 404 when the target username does not exist."""
8200 response = await client.get("/api/v1/users/nonexistent-ghost-user/following-list")
8201 assert response.status_code == 404
8202
8203
8204 @pytest.mark.anyio
8205 async def test_followers_response_includes_following_count(
8206 client: AsyncClient,
8207 db_session: AsyncSession,
8208 ) -> None:
8209 """GET /users/{username}/followers now includes following_count in response."""
8210 await _make_profile(db_session, username="followcountuser")
8211 response = await client.get("/api/v1/users/followcountuser/followers")
8212 assert response.status_code == 200
8213 data = response.json()
8214 assert "followerCount" in data or "follower_count" in data
8215 assert "followingCount" in data or "following_count" in data
8216
8217
8218 @pytest.mark.anyio
8219 async def test_followers_list_empty_for_user_with_no_followers(
8220 client: AsyncClient,
8221 db_session: AsyncSession,
8222 ) -> None:
8223 """followers-list returns an empty list when no one follows the user."""
8224 await _make_profile(db_session, username="lonelyuser295")
8225 response = await client.get("/api/v1/users/lonelyuser295/followers-list")
8226 assert response.status_code == 200
8227 assert response.json() == []
8228
8229
8230 # ---------------------------------------------------------------------------
8231 # Issue #450 — Enhanced commit detail: inline audio player, muse_tags panel,
8232 # reactions, comment thread, cross-references
8233 # ---------------------------------------------------------------------------
8234
8235
8236 @pytest.mark.anyio
8237 async def test_commit_page_has_inline_audio_player_section(
8238 client: AsyncClient,
8239 db_session: AsyncSession,
8240 ) -> None:
8241 """Commit detail page (SSR, issue #583) renders WaveSurfer shell when snapshot_id is set.
8242
8243 Post-SSR migration: the audio player shell (commit-waveform + WaveSurfer script)
8244 is rendered only when the commit has a snapshot_id. Non-existent commits → 404.
8245 """
8246 from datetime import datetime, timezone
8247 from musehub.db.musehub_models import MusehubCommit
8248
8249 repo = MusehubRepo(
8250 name="audio-player-test",
8251 owner="audiouser",
8252 slug="audio-player-test",
8253 visibility="public",
8254 owner_user_id="audio-uid",
8255 )
8256 db_session.add(repo)
8257 await db_session.commit()
8258 await db_session.refresh(repo)
8259
8260 snap_id = "sha256:deadbeefcafe"
8261 commit_id = "c0ffee0000111122223333444455556666c0ffee"
8262 commit = MusehubCommit(
8263 commit_id=commit_id,
8264 repo_id=str(repo.repo_id),
8265 branch="main",
8266 parent_ids=[],
8267 message="Add audio snapshot",
8268 author="audiouser",
8269 timestamp=datetime.now(tz=timezone.utc),
8270 snapshot_id=snap_id,
8271 )
8272 db_session.add(commit)
8273 await db_session.commit()
8274
8275 response = await client.get(f"/audiouser/audio-player-test/commits/{commit_id}")
8276 assert response.status_code == 200
8277 body = response.text
8278 # SSR commit page renders for any repo type
8279 assert "audio-player-test" in body
8280 assert "Add audio snapshot" in body
8281 assert "audioUser" in body.lower() or "audiouser" in body.lower()
8282
8283
8284 @pytest.mark.anyio
8285 async def test_commit_page_inline_player_has_track_selector_js(
8286 client: AsyncClient,
8287 db_session: AsyncSession,
8288 ) -> None:
8289 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8290
8291 Track selector JS was part of the pre-SSR commit.html. The new commit_detail.html
8292 renders a simplified WaveSurfer shell from the commit's snapshot_id.
8293 Non-existent commits return 404 rather than an empty JS shell.
8294 """
8295 repo = MusehubRepo(
8296 name="track-sel-test",
8297 owner="trackuser",
8298 slug="track-sel-test",
8299 visibility="public",
8300 owner_user_id="track-uid",
8301 )
8302 db_session.add(repo)
8303 await db_session.commit()
8304 await db_session.refresh(repo)
8305
8306 commit_id = "aaaa1111bbbb2222cccc3333dddd4444eeee5555"
8307 response = await client.get(f"/trackuser/track-sel-test/commits/{commit_id}")
8308 assert response.status_code == 404
8309
8310
8311 @pytest.mark.anyio
8312 async def test_commit_page_has_muse_tags_panel(
8313 client: AsyncClient,
8314 db_session: AsyncSession,
8315 ) -> None:
8316 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8317
8318 The muse-tags-panel was a JS-only construct in the pre-SSR commit.html.
8319 The new commit_detail.html renders metadata server-side; the muse-tags panel
8320 is not present. Non-existent commits return 404.
8321 """
8322 repo = MusehubRepo(
8323 name="tags-panel-test",
8324 owner="tagsuser",
8325 slug="tags-panel-test",
8326 visibility="public",
8327 owner_user_id="tags-uid",
8328 )
8329 db_session.add(repo)
8330 await db_session.commit()
8331 await db_session.refresh(repo)
8332
8333 commit_id = "1234567890abcdef1234567890abcdef12345678"
8334 response = await client.get(f"/tagsuser/tags-panel-test/commits/{commit_id}")
8335 assert response.status_code == 404
8336
8337
8338 @pytest.mark.anyio
8339 async def test_commit_page_muse_tags_pill_colours_defined(
8340 client: AsyncClient,
8341 db_session: AsyncSession,
8342 ) -> None:
8343 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8344
8345 Muse-pill CSS classes were part of the pre-SSR commit.html analysis panel.
8346 The new commit_detail.html does not include muse-pill classes.
8347 Non-existent commits return 404.
8348 """
8349 repo = MusehubRepo(
8350 name="pill-colour-test",
8351 owner="pilluser",
8352 slug="pill-colour-test",
8353 visibility="public",
8354 owner_user_id="pill-uid",
8355 )
8356 db_session.add(repo)
8357 await db_session.commit()
8358 await db_session.refresh(repo)
8359
8360 commit_id = "abcd1234ef567890abcd1234ef567890abcd1234"
8361 response = await client.get(f"/pilluser/pill-colour-test/commits/{commit_id}")
8362 assert response.status_code == 404
8363
8364
8365 @pytest.mark.anyio
8366 async def test_commit_page_has_cross_references_section(
8367 client: AsyncClient,
8368 db_session: AsyncSession,
8369 ) -> None:
8370 """Commit detail page (SSR, issue #583) returns 404 for non-existent commits.
8371
8372 The cross-references panel (xrefs-body, loadCrossReferences) was a JS-only
8373 construct in the pre-SSR commit.html. The new commit_detail.html does not
8374 include this panel. Non-existent commits return 404.
8375 """
8376 repo = MusehubRepo(
8377 name="xrefs-test",
8378 owner="xrefsuser",
8379 slug="xrefs-test",
8380 visibility="public",
8381 owner_user_id="xrefs-uid",
8382 )
8383 db_session.add(repo)
8384 await db_session.commit()
8385 await db_session.refresh(repo)
8386
8387 commit_id = "face000011112222333344445555666677778888"
8388 response = await client.get(f"/xrefsuser/xrefs-test/commits/{commit_id}")
8389 assert response.status_code == 404
8390
8391
8392 @pytest.mark.anyio
8393 async def test_commit_page_context_passes_listen_and_embed_urls(
8394 client: AsyncClient,
8395 db_session: AsyncSession,
8396 ) -> None:
8397 """commit_page() (SSR, issue #583) injects listenUrl and embedUrl into the JS page-data block.
8398
8399 The SSR template still exposes these URLs server-side for the JS and for
8400 navigation links. Requires the commit to exist in the DB.
8401 """
8402 from datetime import datetime, timezone
8403 from musehub.db.musehub_models import MusehubCommit
8404
8405 repo = MusehubRepo(
8406 name="url-context-test",
8407 owner="urluser",
8408 slug="url-context-test",
8409 visibility="public",
8410 owner_user_id="url-uid",
8411 )
8412 db_session.add(repo)
8413 await db_session.commit()
8414 await db_session.refresh(repo)
8415
8416 commit_id = "dead0000beef1111dead0000beef1111dead0000"
8417 commit = MusehubCommit(
8418 commit_id=commit_id,
8419 repo_id=str(repo.repo_id),
8420 branch="main",
8421 parent_ids=[],
8422 message="URL context test commit",
8423 author="urluser",
8424 timestamp=datetime.now(tz=timezone.utc),
8425 )
8426 db_session.add(commit)
8427 await db_session.commit()
8428
8429 response = await client.get(f"/urluser/url-context-test/commits/{commit_id}")
8430 assert response.status_code == 200
8431 body = response.text
8432 assert "audioUrl" in body
8433 assert "viewerType" in body
8434 # /view/ link only appears for piano_roll domain repos; diff link is always present
8435 assert f"/commits/{commit_id}/diff" in body
8436
8437
8438 # ---------------------------------------------------------------------------
8439 # Issue #442 — Repo landing page enrichment panels
8440 # Explore page — filter sidebar + inline audio preview
8441 # ---------------------------------------------------------------------------
8442
8443
8444 @pytest.mark.anyio
8445 async def test_repo_home_contributors_panel_js(
8446 client: AsyncClient,
8447 db_session: AsyncSession,
8448 ) -> None:
8449 """Repo home page links to the credits page (SSR — no client-side contributor panel JS)."""
8450 repo = MusehubRepo(
8451 name="contrib-panel-test",
8452 owner="contribowner",
8453 slug="contrib-panel-test",
8454 visibility="public",
8455 owner_user_id="contrib-uid",
8456 )
8457 db_session.add(repo)
8458 await db_session.commit()
8459
8460 response = await client.get("/contribowner/contrib-panel-test")
8461 assert response.status_code == 200
8462 body = response.text
8463 assert "MuseHub" in body
8464 assert "contribowner" in body
8465 assert "contrib-panel-test" in body
8466
8467
8468 @pytest.mark.anyio
8469 async def test_repo_home_activity_heatmap_js(
8470 client: AsyncClient,
8471 db_session: AsyncSession,
8472 ) -> None:
8473 """Repo home page renders SSR repo metadata (no client-side heatmap JS)."""
8474 repo = MusehubRepo(
8475 name="heatmap-panel-test",
8476 owner="heatmapowner",
8477 slug="heatmap-panel-test",
8478 visibility="public",
8479 owner_user_id="heatmap-uid",
8480 )
8481 db_session.add(repo)
8482 await db_session.commit()
8483
8484 response = await client.get("/heatmapowner/heatmap-panel-test")
8485 assert response.status_code == 200
8486 body = response.text
8487 assert "MuseHub" in body
8488 assert "heatmapowner" in body
8489 assert "heatmap-panel-test" in body
8490
8491
8492 @pytest.mark.anyio
8493 async def test_repo_home_instrument_bar_js(
8494 client: AsyncClient,
8495 db_session: AsyncSession,
8496 ) -> None:
8497 """Repo home page renders SSR repo metadata (no client-side instrument-bar JS)."""
8498 repo = MusehubRepo(
8499 name="instrbar-panel-test",
8500 owner="instrbarowner",
8501 slug="instrbar-panel-test",
8502 visibility="public",
8503 owner_user_id="instrbar-uid",
8504 )
8505 db_session.add(repo)
8506 await db_session.commit()
8507
8508 response = await client.get("/instrbarowner/instrbar-panel-test")
8509 assert response.status_code == 200
8510 body = response.text
8511 assert "MuseHub" in body
8512 assert "instrbarowner" in body
8513 assert "instrbar-panel-test" in body
8514
8515
8516 @pytest.mark.anyio
8517 async def test_repo_home_clone_widget_renders(
8518 client: AsyncClient,
8519 db_session: AsyncSession,
8520 ) -> None:
8521 """Repo home page renders clone URLs server-side into read-only inputs."""
8522 repo = MusehubRepo(
8523 name="clone-widget-test",
8524 owner="cloneowner",
8525 slug="clone-widget-test",
8526 visibility="public",
8527 owner_user_id="clone-uid",
8528 )
8529 db_session.add(repo)
8530 await db_session.commit()
8531
8532 response = await client.get("/cloneowner/clone-widget-test")
8533 assert response.status_code == 200
8534 body = response.text
8535
8536 # Clone URLs injected server-side by repo_page()
8537 assert "musehub://cloneowner/clone-widget-test" in body
8538 assert "https://musehub.ai/cloneowner/clone-widget-test" in body
8539 # SSH is not supported — must not appear
8540 assert "git@" not in body
8541 # SSR clone widget DOM elements
8542 assert "clone-input" in body
8543 async def test_explore_page_returns_200(
8544 client: AsyncClient,
8545 ) -> None:
8546 """GET /explore returns 200 without authentication."""
8547 response = await client.get("/explore")
8548 assert response.status_code == 200
8549
8550
8551 @pytest.mark.anyio
8552 async def test_explore_page_has_filter_sidebar(
8553 client: AsyncClient,
8554 ) -> None:
8555 """Explore page renders a filter sidebar with sort, license, and clear-filters sections."""
8556 response = await client.get("/explore")
8557 assert response.status_code == 200
8558 body = response.text
8559 assert "ex-sidebar" in body
8560 assert "Clear filters" in body
8561 assert "Sort by" in body
8562 assert "License" in body
8563
8564
8565 @pytest.mark.anyio
8566 async def test_explore_page_has_sort_options(
8567 client: AsyncClient,
8568 ) -> None:
8569 """Explore page sidebar includes all four sort radio options."""
8570 response = await client.get("/explore")
8571 assert response.status_code == 200
8572 body = response.text
8573 assert "Most starred" in body
8574 assert "Recently updated" in body
8575 assert "Most forked" in body
8576 assert "Trending" in body
8577
8578
8579 @pytest.mark.anyio
8580 async def test_explore_page_has_license_options(
8581 client: AsyncClient,
8582 ) -> None:
8583 """Explore page sidebar includes the expected license filter options."""
8584 response = await client.get("/explore")
8585 assert response.status_code == 200
8586 body = response.text
8587 assert "CC0" in body
8588 assert "CC BY" in body
8589 assert "CC BY-SA" in body
8590 assert "CC BY-NC" in body
8591 assert "All Rights Reserved" in body
8592
8593
8594 @pytest.mark.anyio
8595 async def test_explore_page_has_repo_grid(
8596 client: AsyncClient,
8597 ) -> None:
8598 """Explore page includes the repo grid and JS discover API loader."""
8599 response = await client.get("/explore")
8600 assert response.status_code == 200
8601 body = response.text
8602 assert "repo-grid" in body
8603 assert "filter-form" in body
8604
8605
8606 @pytest.mark.anyio
8607 async def test_explore_page_has_audio_preview_js(
8608 client: AsyncClient,
8609 ) -> None:
8610 """Explore page renders the filter sidebar and repo grid (SSR, no inline audio-preview JS)."""
8611 response = await client.get("/explore")
8612 assert response.status_code == 200
8613 body = response.text
8614 assert "filter-form" in body
8615 assert "ex-browse" in body
8616 assert "repo-grid" in body
8617
8618
8619 @pytest.mark.anyio
8620 async def test_explore_page_default_sort_stars(
8621 client: AsyncClient,
8622 ) -> None:
8623 """Explore page defaults to 'stars' sort when no sort param given."""
8624 response = await client.get("/explore")
8625 assert response.status_code == 200
8626 body = response.text
8627 # 'stars' radio should be pre-checked (default sort)
8628 assert 'value="stars"' in body
8629 assert 'checked' in body
8630
8631
8632 @pytest.mark.anyio
8633 async def test_explore_page_sort_param_honoured(
8634 client: AsyncClient,
8635 ) -> None:
8636 """Explore page honours the ?sort= query param for pre-selecting a sort option."""
8637 response = await client.get("/explore?sort=updated")
8638 assert response.status_code == 200
8639 body = response.text
8640 assert 'value="updated"' in body
8641
8642
8643 @pytest.mark.anyio
8644 async def test_explore_page_no_auth_required(
8645 client: AsyncClient,
8646 ) -> None:
8647 """Explore page is publicly accessible — no JWT required (zero-friction discovery)."""
8648 response = await client.get("/explore")
8649 assert response.status_code == 200
8650 assert response.status_code != 401
8651 assert response.status_code != 403
8652
8653
8654 @pytest.mark.anyio
8655 async def test_explore_page_chip_toggle_js(
8656 client: AsyncClient,
8657 ) -> None:
8658 """Explore page dispatches explore.ts module (toggleChip is now in explore.ts)."""
8659 response = await client.get("/explore")
8660 assert response.status_code == 200
8661 body = response.text
8662 assert '"page": "explore"' in body
8663 # filter-form is always present; data-filter chips appear when repos with tags exist
8664 assert "filter-form" in body
8665
8666
8667 @pytest.mark.anyio
8668 async def test_explore_page_get_params_preserved(
8669 client: AsyncClient,
8670 ) -> None:
8671 """Explore page accepts lang, license, topic, sort GET params without error."""
8672 response = await client.get(
8673 "/explore?lang=piano&license=CC0&topic=jazz&sort=stars"
8674 )
8675 assert response.status_code == 200