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