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