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