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