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