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