gabriel / musehub public
test_musehub_repos.py python
2,089 lines 63.8 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Tests for MuseHub repo, branch, and commit endpoints.
2
3 Covers every acceptance criterion:
4 - POST /musehub/repos returns 201 with correct fields
5 - POST requires auth — unauthenticated requests return 401
6 - GET /repos/{repo_id} returns 200; 404 for unknown repo
7 - GET /repos/{repo_id}/branches returns empty list on new repo
8 - GET /repos/{repo_id}/commits returns newest first, respects ?limit
9
10 Covers (compare view API endpoint):
11 - test_compare_radar_data — compare endpoint returns 5 dimension scores
12 - test_compare_commit_list — commits unique to head are listed
13 - test_compare_unknown_ref_404 — unknown ref returns 422
14
15 All tests use the shared ``client`` and ``auth_headers`` fixtures from conftest.py.
16 """
17 from __future__ import annotations
18
19 import pytest
20 from httpx import AsyncClient
21 from sqlalchemy.ext.asyncio import AsyncSession
22
23 from musehub.db.musehub_models import MusehubCommit, MusehubRepo
24 from musehub.services import musehub_repository
25 from musehub.types.json_types import StrDict
26
27
28 # ---------------------------------------------------------------------------
29 # POST /musehub/repos
30 # ---------------------------------------------------------------------------
31
32
33 async def test_create_repo_returns_201(
34 client: AsyncClient,
35 auth_headers: StrDict,
36 ) -> None:
37 """POST /musehub/repos creates a repo and returns all required fields."""
38 response = await client.post(
39 "/api/repos",
40 json={"name": "my-beats", "owner": "testuser", "visibility": "private"},
41 headers=auth_headers,
42 )
43 assert response.status_code == 201
44 body = response.json()
45 assert body["name"] == "my-beats"
46 assert body["visibility"] == "private"
47 assert "repoId" in body
48 assert "cloneUrl" in body
49 assert "ownerUserId" in body
50 assert "createdAt" in body
51
52
53 async def test_create_repo_requires_auth(client: AsyncClient) -> None:
54 """POST /musehub/repos returns 401 without a MSign Authorization header."""
55 response = await client.post(
56 "/api/repos",
57 json={"name": "my-beats", "owner": "testuser"},
58 )
59 assert response.status_code == 401
60
61
62 async def test_create_repo_default_visibility_is_public(
63 client: AsyncClient,
64 auth_headers: StrDict,
65 ) -> None:
66 """Omitting visibility defaults to 'public'."""
67 response = await client.post(
68 "/api/repos",
69 json={"name": "silent-sessions", "owner": "testuser"},
70 headers=auth_headers,
71 )
72 assert response.status_code == 201
73 assert response.json()["visibility"] == "public"
74
75
76 # ---------------------------------------------------------------------------
77 # GET /repos/{repo_id}
78 # ---------------------------------------------------------------------------
79
80
81 async def test_get_repo_returns_200(
82 client: AsyncClient,
83 auth_headers: StrDict,
84 ) -> None:
85 """GET /repos/{repo_id} returns the repo after creation."""
86 create = await client.post(
87 "/api/repos",
88 json={"name": "jazz-sessions", "owner": "testuser"},
89 headers=auth_headers,
90 )
91 assert create.status_code == 201
92 repo_id = create.json()["repoId"]
93
94 response = await client.get(f"/api/repos/{repo_id}", headers=auth_headers)
95 assert response.status_code == 200
96 assert response.json()["repoId"] == repo_id
97 assert response.json()["name"] == "jazz-sessions"
98
99
100 async def test_get_repo_not_found_returns_404(
101 client: AsyncClient,
102 auth_headers: StrDict,
103 ) -> None:
104 """GET /repos/{repo_id} returns 404 for unknown repo."""
105 response = await client.get(
106 "/api/repos/does-not-exist",
107 headers=auth_headers,
108 )
109 assert response.status_code == 404
110
111
112 async def test_get_nonexistent_repo_returns_404_without_auth(client: AsyncClient) -> None:
113 """GET /repos/{repo_id} returns 404 for a non-existent repo without auth.
114
115 Uses optional_token — auth is visibility-based; missing repo → 404 before auth check.
116 """
117 response = await client.get("/api/repos/non-existent-repo-id")
118 assert response.status_code == 404
119
120
121 # ---------------------------------------------------------------------------
122 # GET /repos/{repo_id}/branches
123 # ---------------------------------------------------------------------------
124
125
126 async def test_list_branches_empty_on_new_repo(
127 client: AsyncClient,
128 auth_headers: StrDict,
129 ) -> None:
130 """A newly created repo has an empty branches list when not initialized."""
131 create = await client.post(
132 "/api/repos",
133 json={"name": "drum-patterns", "owner": "testuser", "initialize": False},
134 headers=auth_headers,
135 )
136 repo_id = create.json()["repoId"]
137
138 response = await client.get(
139 f"/api/repos/{repo_id}/branches",
140 headers=auth_headers,
141 )
142 assert response.status_code == 200
143 assert response.json()["branches"] == []
144
145
146 async def test_list_branches_not_found_returns_404(
147 client: AsyncClient,
148 auth_headers: StrDict,
149 ) -> None:
150 """GET /branches returns 404 when the repo doesn't exist."""
151 response = await client.get(
152 "/api/repos/ghost-repo/branches",
153 headers=auth_headers,
154 )
155 assert response.status_code == 404
156
157
158 # ---------------------------------------------------------------------------
159 # GET /repos/{repo_id}/commits
160 # ---------------------------------------------------------------------------
161
162
163 async def test_list_commits_empty_on_new_repo(
164 client: AsyncClient,
165 auth_headers: StrDict,
166 ) -> None:
167 """A new repo has no commits when initialize=false."""
168 create = await client.post(
169 "/api/repos",
170 json={"name": "empty-repo", "owner": "testuser", "initialize": False},
171 headers=auth_headers,
172 )
173 repo_id = create.json()["repoId"]
174
175 response = await client.get(
176 f"/api/repos/{repo_id}/commits",
177 headers=auth_headers,
178 )
179 assert response.status_code == 200
180 body = response.json()
181 assert body["commits"] == []
182 assert body["total"] == 0
183
184
185 async def test_list_commits_returns_newest_first(
186 client: AsyncClient,
187 auth_headers: StrDict,
188 db_session: AsyncSession,
189 ) -> None:
190 """Commits are returned newest-first after being pushed."""
191 from datetime import datetime, timezone, timedelta
192
193 # Create repo via API (no init commit so we control the full history)
194 create = await client.post(
195 "/api/repos",
196 json={"name": "ordered-commits", "owner": "testuser", "initialize": False},
197 headers=auth_headers,
198 )
199 repo_id = create.json()["repoId"]
200
201 # Insert two commits directly with known timestamps
202 now = datetime.now(tz=timezone.utc)
203 older = MusehubCommit(
204 commit_id="aaa111",
205 repo_id=repo_id,
206 branch="main",
207 parent_ids=[],
208 message="first",
209 author="gabriel",
210 timestamp=now - timedelta(hours=1),
211 )
212 newer = MusehubCommit(
213 commit_id="bbb222",
214 repo_id=repo_id,
215 branch="main",
216 parent_ids=["aaa111"],
217 message="second",
218 author="gabriel",
219 timestamp=now,
220 )
221 db_session.add_all([older, newer])
222 await db_session.commit()
223
224 response = await client.get(
225 f"/api/repos/{repo_id}/commits",
226 headers=auth_headers,
227 )
228 assert response.status_code == 200
229 commits = response.json()["commits"]
230 assert len(commits) == 2
231 assert commits[0]["commitId"] == "bbb222"
232 assert commits[1]["commitId"] == "aaa111"
233
234
235 async def test_list_commits_limit_param(
236 client: AsyncClient,
237 auth_headers: StrDict,
238 db_session: AsyncSession,
239 ) -> None:
240 """?limit=1 returns exactly 1 commit."""
241 from datetime import datetime, timezone, timedelta
242
243 create = await client.post(
244 "/api/repos",
245 json={"name": "limited-repo", "owner": "testuser", "initialize": False},
246 headers=auth_headers,
247 )
248 repo_id = create.json()["repoId"]
249
250 now = datetime.now(tz=timezone.utc)
251 for i in range(3):
252 db_session.add(
253 MusehubCommit(
254 commit_id=f"commit-{i}",
255 repo_id=repo_id,
256 branch="main",
257 parent_ids=[],
258 message=f"commit {i}",
259 author="gabriel",
260 timestamp=now + timedelta(seconds=i),
261 )
262 )
263 await db_session.commit()
264
265 response = await client.get(
266 f"/api/repos/{repo_id}/commits?limit=1",
267 headers=auth_headers,
268 )
269 assert response.status_code == 200
270 body = response.json()
271 assert len(body["commits"]) == 1
272 assert body["total"] == 3
273
274
275 # ---------------------------------------------------------------------------
276 # Service layer — direct DB tests (no HTTP)
277 # ---------------------------------------------------------------------------
278
279
280 async def test_create_repo_service_persists_to_db(db_session: AsyncSession) -> None:
281 """musehub_repository.create_repo() persists the row."""
282 repo = await musehub_repository.create_repo(
283 db_session,
284 name="service-test-repo",
285 owner="testuser",
286 visibility="public",
287 owner_user_id="user-abc",
288 )
289 await db_session.commit()
290
291 fetched = await musehub_repository.get_repo(db_session, repo.repo_id)
292 assert fetched is not None
293 assert fetched.name == "service-test-repo"
294 assert fetched.visibility == "public"
295
296
297 async def test_get_repo_returns_none_when_missing(db_session: AsyncSession) -> None:
298 """get_repo() returns None for an unknown repo_id."""
299 result = await musehub_repository.get_repo(db_session, "nonexistent-id")
300 assert result is None
301
302
303 async def test_list_branches_returns_empty_for_new_repo(db_session: AsyncSession) -> None:
304 """list_branches() returns [] for a repo with no branches."""
305 repo = await musehub_repository.create_repo(
306 db_session,
307 name="branchless",
308 owner="testuser",
309 visibility="private",
310 owner_user_id="user-x",
311 )
312 await db_session.commit()
313 branches = await musehub_repository.list_branches(db_session, repo.repo_id)
314 assert branches == []
315
316
317 # ---------------------------------------------------------------------------
318 # GET /repos/{repo_id}/divergence
319 # ---------------------------------------------------------------------------
320
321
322 async def test_divergence_endpoint_returns_five_dimensions(
323 client: AsyncClient,
324 auth_headers: StrDict,
325 db_session: AsyncSession,
326 ) -> None:
327 """GET /divergence returns five dimension scores with level labels."""
328 from datetime import datetime, timezone, timedelta
329
330 create = await client.post(
331 "/api/repos",
332 json={"name": "divergence-test-repo", "owner": "testuser"},
333 headers=auth_headers,
334 )
335 assert create.status_code == 201
336 repo_id = create.json()["repoId"]
337
338 now = datetime.now(tz=timezone.utc)
339 db_session.add(
340 MusehubCommit(
341 commit_id="aaa-melody",
342 repo_id=repo_id,
343 branch="main",
344 parent_ids=[],
345 message="add lead melody line",
346 author="alice",
347 timestamp=now - timedelta(hours=2),
348 )
349 )
350 db_session.add(
351 MusehubCommit(
352 commit_id="bbb-chord",
353 repo_id=repo_id,
354 branch="feature",
355 parent_ids=[],
356 message="update chord progression",
357 author="bob",
358 timestamp=now - timedelta(hours=1),
359 )
360 )
361 await db_session.commit()
362
363 response = await client.get(
364 f"/api/repos/{repo_id}/divergence?branch_a=main&branch_b=feature",
365 headers=auth_headers,
366 )
367 assert response.status_code == 200
368 body = response.json()
369 assert "dimensions" in body
370 assert len(body["dimensions"]) == 5
371
372 dim_names = {d["dimension"] for d in body["dimensions"]}
373 assert dim_names == {"melodic", "harmonic", "rhythmic", "structural", "dynamic"}
374
375 for dim in body["dimensions"]:
376 assert "level" in dim
377 assert dim["level"] in {"NONE", "LOW", "MED", "HIGH"}
378 assert "score" in dim
379 assert 0.0 <= dim["score"] <= 1.0
380
381
382 async def test_divergence_overall_score_is_mean_of_dimensions(
383 client: AsyncClient,
384 auth_headers: StrDict,
385 db_session: AsyncSession,
386 ) -> None:
387 """Overall divergence score equals the mean of all five dimension scores."""
388 from datetime import datetime, timezone, timedelta
389
390 create = await client.post(
391 "/api/repos",
392 json={"name": "divergence-mean-repo", "owner": "testuser"},
393 headers=auth_headers,
394 )
395 repo_id = create.json()["repoId"]
396
397 now = datetime.now(tz=timezone.utc)
398 db_session.add(
399 MusehubCommit(
400 commit_id="c1-beat",
401 repo_id=repo_id,
402 branch="alpha",
403 parent_ids=[],
404 message="rework drum beat groove",
405 author="producer-a",
406 timestamp=now - timedelta(hours=3),
407 )
408 )
409 db_session.add(
410 MusehubCommit(
411 commit_id="c2-mix",
412 repo_id=repo_id,
413 branch="beta",
414 parent_ids=[],
415 message="fix master volume level",
416 author="producer-b",
417 timestamp=now - timedelta(hours=2),
418 )
419 )
420 await db_session.commit()
421
422 response = await client.get(
423 f"/api/repos/{repo_id}/divergence?branch_a=alpha&branch_b=beta",
424 headers=auth_headers,
425 )
426 assert response.status_code == 200
427 body = response.json()
428
429 dims = body["dimensions"]
430 computed_mean = round(sum(d["score"] for d in dims) / len(dims), 4)
431 assert abs(body["overallScore"] - computed_mean) < 1e-6
432
433
434 async def test_divergence_json_response_structure(
435 client: AsyncClient,
436 auth_headers: StrDict,
437 db_session: AsyncSession,
438 ) -> None:
439 """JSON response has all required top-level fields and camelCase keys."""
440 from datetime import datetime, timezone, timedelta
441
442 create = await client.post(
443 "/api/repos",
444 json={"name": "divergence-struct-repo", "owner": "testuser"},
445 headers=auth_headers,
446 )
447 repo_id = create.json()["repoId"]
448
449 now = datetime.now(tz=timezone.utc)
450 for i, (branch, msg) in enumerate(
451 [("main", "add melody riff"), ("dev", "update chorus section")]
452 ):
453 db_session.add(
454 MusehubCommit(
455 commit_id=f"struct-{i}",
456 repo_id=repo_id,
457 branch=branch,
458 parent_ids=[],
459 message=msg,
460 author="test",
461 timestamp=now + timedelta(seconds=i),
462 )
463 )
464 await db_session.commit()
465
466 response = await client.get(
467 f"/api/repos/{repo_id}/divergence?branch_a=main&branch_b=dev",
468 headers=auth_headers,
469 )
470 assert response.status_code == 200
471 body = response.json()
472
473 assert body["repoId"] == repo_id
474 assert body["branchA"] == "main"
475 assert body["branchB"] == "dev"
476 assert "commonAncestor" in body
477 assert "overallScore" in body
478 assert isinstance(body["overallScore"], float)
479 assert isinstance(body["dimensions"], list)
480 assert len(body["dimensions"]) == 5
481
482 for dim in body["dimensions"]:
483 assert "dimension" in dim
484 assert "level" in dim
485 assert "score" in dim
486 assert "description" in dim
487 assert "branchACommits" in dim
488 assert "branchBCommits" in dim
489
490
491 async def test_divergence_endpoint_returns_404_for_unknown_repo(
492 client: AsyncClient,
493 auth_headers: StrDict,
494 ) -> None:
495 """GET /divergence returns 404 for an unknown repo."""
496 response = await client.get(
497 "/api/repos/no-such-repo/divergence?branch_a=a&branch_b=b",
498 headers=auth_headers,
499 )
500 assert response.status_code == 404
501
502
503 async def test_divergence_endpoint_returns_422_for_empty_branch(
504 client: AsyncClient,
505 auth_headers: StrDict,
506 db_session: AsyncSession,
507 ) -> None:
508 """GET /divergence returns 422 when a branch has no commits."""
509 create = await client.post(
510 "/api/repos",
511 json={"name": "empty-branch-repo", "owner": "testuser"},
512 headers=auth_headers,
513 )
514 repo_id = create.json()["repoId"]
515
516 response = await client.get(
517 f"/api/repos/{repo_id}/divergence?branch_a=ghost&branch_b=also-ghost",
518 headers=auth_headers,
519 )
520 assert response.status_code == 422
521
522
523
524 # ---------------------------------------------------------------------------
525 # GET /repos/{repo_id}/dag
526 # ---------------------------------------------------------------------------
527
528
529 async def test_graph_dag_endpoint_returns_empty_for_new_repo(
530 client: AsyncClient,
531 auth_headers: StrDict,
532 ) -> None:
533 """GET /dag returns empty nodes/edges for a repo with no commits (initialize=false)."""
534 create = await client.post(
535 "/api/repos",
536 json={"name": "dag-empty", "owner": "testuser", "initialize": False},
537 headers=auth_headers,
538 )
539 repo_id = create.json()["repoId"]
540
541 response = await client.get(
542 f"/api/repos/{repo_id}/dag",
543 headers=auth_headers,
544 )
545 assert response.status_code == 200
546 body = response.json()
547 assert body["nodes"] == []
548 assert body["edges"] == []
549 assert body["headCommitId"] is None
550
551
552 async def test_graph_dag_has_edges(
553 client: AsyncClient,
554 auth_headers: StrDict,
555 db_session: AsyncSession,
556 ) -> None:
557 """DAG endpoint returns correct edges representing parent relationships."""
558 from datetime import datetime, timezone, timedelta
559
560 create = await client.post(
561 "/api/repos",
562 json={"name": "dag-edges", "owner": "testuser", "initialize": False},
563 headers=auth_headers,
564 )
565 repo_id = create.json()["repoId"]
566
567 now = datetime.now(tz=timezone.utc)
568 root = MusehubCommit(
569 commit_id="root111",
570 repo_id=repo_id,
571 branch="main",
572 parent_ids=[],
573 message="root commit",
574 author="gabriel",
575 timestamp=now - timedelta(hours=2),
576 )
577 child = MusehubCommit(
578 commit_id="child222",
579 repo_id=repo_id,
580 branch="main",
581 parent_ids=["root111"],
582 message="child commit",
583 author="gabriel",
584 timestamp=now - timedelta(hours=1),
585 )
586 db_session.add_all([root, child])
587 await db_session.commit()
588
589 response = await client.get(
590 f"/api/repos/{repo_id}/dag",
591 headers=auth_headers,
592 )
593 assert response.status_code == 200
594 body = response.json()
595 nodes = body["nodes"]
596 edges = body["edges"]
597
598 assert len(nodes) == 2
599 # Verify edge: child → root
600 assert any(e["source"] == "child222" and e["target"] == "root111" for e in edges)
601
602
603 async def test_graph_dag_endpoint_topological_order(
604 client: AsyncClient,
605 auth_headers: StrDict,
606 db_session: AsyncSession,
607 ) -> None:
608 """DAG endpoint returns nodes in topological order (oldest ancestor first)."""
609 from datetime import datetime, timedelta, timezone
610
611 create = await client.post(
612 "/api/repos",
613 json={"name": "dag-topo", "owner": "testuser"},
614 headers=auth_headers,
615 )
616 repo_id = create.json()["repoId"]
617
618 now = datetime.now(tz=timezone.utc)
619 commits = [
620 MusehubCommit(
621 commit_id="topo-a",
622 repo_id=repo_id,
623 branch="main",
624 parent_ids=[],
625 message="root",
626 author="gabriel",
627 timestamp=now - timedelta(hours=3),
628 ),
629 MusehubCommit(
630 commit_id="topo-b",
631 repo_id=repo_id,
632 branch="main",
633 parent_ids=["topo-a"],
634 message="second",
635 author="gabriel",
636 timestamp=now - timedelta(hours=2),
637 ),
638 MusehubCommit(
639 commit_id="topo-c",
640 repo_id=repo_id,
641 branch="main",
642 parent_ids=["topo-b"],
643 message="third",
644 author="gabriel",
645 timestamp=now - timedelta(hours=1),
646 ),
647 ]
648 db_session.add_all(commits)
649 await db_session.commit()
650
651 response = await client.get(
652 f"/api/repos/{repo_id}/dag",
653 headers=auth_headers,
654 )
655 assert response.status_code == 200
656 node_ids = [n["commitId"] for n in response.json()["nodes"]]
657 # Root must appear before children in topological order
658 assert node_ids.index("topo-a") < node_ids.index("topo-b")
659 assert node_ids.index("topo-b") < node_ids.index("topo-c")
660
661
662 async def test_graph_dag_nonexistent_repo_returns_404_without_auth(client: AsyncClient) -> None:
663 """GET /dag returns 404 for a non-existent repo without a token.
664
665 Uses optional_token — auth is visibility-based; missing repo → 404.
666 """
667 response = await client.get("/api/repos/non-existent-repo/dag")
668 assert response.status_code == 404
669
670
671 async def test_graph_dag_404_for_unknown_repo(
672 client: AsyncClient,
673 auth_headers: StrDict,
674 ) -> None:
675 """GET /dag returns 404 for a non-existent repo."""
676 response = await client.get(
677 "/api/repos/ghost-repo-dag/dag",
678 headers=auth_headers,
679 )
680 assert response.status_code == 404
681
682
683 async def test_graph_json_response_has_required_fields(
684 client: AsyncClient,
685 auth_headers: StrDict,
686 db_session: AsyncSession,
687 ) -> None:
688 """DAG JSON response includes nodes (with required fields) and edges arrays."""
689 from datetime import datetime, timezone
690
691 create = await client.post(
692 "/api/repos",
693 json={"name": "dag-fields", "owner": "testuser"},
694 headers=auth_headers,
695 )
696 repo_id = create.json()["repoId"]
697
698 db_session.add(
699 MusehubCommit(
700 commit_id="fields-aaa",
701 repo_id=repo_id,
702 branch="main",
703 parent_ids=[],
704 message="check fields",
705 author="tester",
706 timestamp=datetime.now(tz=timezone.utc),
707 )
708 )
709 await db_session.commit()
710
711 response = await client.get(
712 f"/api/repos/{repo_id}/dag",
713 headers=auth_headers,
714 )
715 assert response.status_code == 200
716 body = response.json()
717 assert "nodes" in body
718 assert "edges" in body
719 assert "headCommitId" in body
720
721 node = body["nodes"][0]
722 for field in ("commitId", "message", "author", "timestamp", "branch", "parentIds", "isHead"):
723 assert field in node, f"Missing field '{field}' in DAG node"
724
725 # ---------------------------------------------------------------------------
726 # GET /repos/{repo_id}/credits
727 # ---------------------------------------------------------------------------
728
729
730 async def _seed_credits_repo(db_session: AsyncSession) -> str:
731 """Create a repo with commits from two distinct authors and return repo_id."""
732 from datetime import datetime, timezone, timedelta
733
734 repo = MusehubRepo(name="liner-notes",
735 owner="testuser",
736 slug="liner-notes", visibility="public", owner_user_id="producer-1")
737 db_session.add(repo)
738 await db_session.flush()
739 repo_id = str(repo.repo_id)
740
741 now = datetime.now(tz=timezone.utc)
742 # Alice: 2 commits (most prolific), most recent 1 day ago
743 db_session.add(
744 MusehubCommit(
745 commit_id="alice-001",
746 repo_id=repo_id,
747 branch="main",
748 parent_ids=[],
749 message="compose the main melody",
750 author="Alice",
751 timestamp=now - timedelta(days=3),
752 )
753 )
754 db_session.add(
755 MusehubCommit(
756 commit_id="alice-002",
757 repo_id=repo_id,
758 branch="main",
759 parent_ids=["alice-001"],
760 message="mix the final arrangement",
761 author="Alice",
762 timestamp=now - timedelta(days=1),
763 )
764 )
765 # Bob: 1 commit, last active 5 days ago
766 db_session.add(
767 MusehubCommit(
768 commit_id="bob-001",
769 repo_id=repo_id,
770 branch="main",
771 parent_ids=[],
772 message="arrange the bridge section",
773 author="Bob",
774 timestamp=now - timedelta(days=5),
775 )
776 )
777 await db_session.commit()
778 return repo_id
779
780
781 async def test_credits_aggregation(
782 client: AsyncClient,
783 db_session: AsyncSession,
784 auth_headers: StrDict,
785 ) -> None:
786 """GET /api/repos/{repo_id}/credits aggregates contributors from commits."""
787 repo_id = await _seed_credits_repo(db_session)
788 response = await client.get(
789 f"/api/repos/{repo_id}/credits",
790 headers=auth_headers,
791 )
792 assert response.status_code == 200
793 body = response.json()
794 assert body["totalContributors"] == 2
795 authors = {c["author"] for c in body["contributors"]}
796 assert "Alice" in authors
797 assert "Bob" in authors
798
799
800 async def test_credits_sorted_by_count(
801 client: AsyncClient,
802 db_session: AsyncSession,
803 auth_headers: StrDict,
804 ) -> None:
805 """Default sort (count) puts the most prolific contributor first."""
806 repo_id = await _seed_credits_repo(db_session)
807 response = await client.get(
808 f"/api/repos/{repo_id}/credits?sort=count",
809 headers=auth_headers,
810 )
811 assert response.status_code == 200
812 contributors = response.json()["contributors"]
813 assert contributors[0]["author"] == "Alice"
814 assert contributors[0]["sessionCount"] == 2
815
816
817 async def test_credits_sorted_by_recency(
818 client: AsyncClient,
819 db_session: AsyncSession,
820 auth_headers: StrDict,
821 ) -> None:
822 """sort=recency puts the most recently active contributor first."""
823 repo_id = await _seed_credits_repo(db_session)
824 response = await client.get(
825 f"/api/repos/{repo_id}/credits?sort=recency",
826 headers=auth_headers,
827 )
828 assert response.status_code == 200
829 contributors = response.json()["contributors"]
830 # Alice has a commit 1 day ago; Bob's last was 5 days ago
831 assert contributors[0]["author"] == "Alice"
832
833
834 async def test_credits_sorted_by_alpha(
835 client: AsyncClient,
836 db_session: AsyncSession,
837 auth_headers: StrDict,
838 ) -> None:
839 """sort=alpha returns contributors in alphabetical order."""
840 repo_id = await _seed_credits_repo(db_session)
841 response = await client.get(
842 f"/api/repos/{repo_id}/credits?sort=alpha",
843 headers=auth_headers,
844 )
845 assert response.status_code == 200
846 contributors = response.json()["contributors"]
847 authors = [c["author"] for c in contributors]
848 assert authors == sorted(authors, key=str.lower)
849
850
851 async def test_credits_contribution_types_inferred(
852 client: AsyncClient,
853 db_session: AsyncSession,
854 auth_headers: StrDict,
855 ) -> None:
856 """Contribution types are inferred from commit messages."""
857 repo_id = await _seed_credits_repo(db_session)
858 response = await client.get(
859 f"/api/repos/{repo_id}/credits",
860 headers=auth_headers,
861 )
862 assert response.status_code == 200
863 contributors = response.json()["contributors"]
864 alice = next(c for c in contributors if c["author"] == "Alice")
865 # Alice's commits mention "compose" and "mix"
866 types = set(alice["contributionTypes"])
867 assert len(types) > 0
868
869
870 async def test_credits_404_for_unknown_repo(
871 client: AsyncClient,
872 auth_headers: StrDict,
873 ) -> None:
874 """GET /api/repos/{unknown}/credits returns 404."""
875 response = await client.get(
876 "/api/repos/does-not-exist/credits",
877 headers=auth_headers,
878 )
879 assert response.status_code == 404
880
881
882 async def test_credits_requires_auth(
883 client: AsyncClient,
884 db_session: AsyncSession,
885 ) -> None:
886 """GET /api/repos/{repo_id}/credits returns 401 without MSign auth."""
887 repo = MusehubRepo(name="auth-test-repo",
888 owner="testuser",
889 slug="auth-test-repo", visibility="private", owner_user_id="u1")
890 db_session.add(repo)
891 await db_session.commit()
892 await db_session.refresh(repo)
893 response = await client.get(f"/api/repos/{repo.repo_id}/credits")
894 assert response.status_code == 401
895
896
897 async def test_credits_invalid_sort_param(
898 client: AsyncClient,
899 db_session: AsyncSession,
900 auth_headers: StrDict,
901 ) -> None:
902 """GET /api/repos/{repo_id}/credits with invalid sort returns 422."""
903 repo = MusehubRepo(name="sort-test",
904 owner="testuser",
905 slug="sort-test", visibility="private", owner_user_id="u1")
906 db_session.add(repo)
907 await db_session.commit()
908 await db_session.refresh(repo)
909 response = await client.get(
910 f"/api/repos/{repo.repo_id}/credits?sort=invalid",
911 headers=auth_headers,
912 )
913 assert response.status_code == 422
914
915
916 async def test_credits_aggregation_service_direct(db_session: AsyncSession) -> None:
917 """musehub_credits.aggregate_credits() returns correct data without HTTP layer."""
918 from datetime import datetime, timezone
919
920 from musehub.services import musehub_credits
921
922 repo = MusehubRepo(name="direct-test",
923 owner="testuser",
924 slug="direct-test", visibility="private", owner_user_id="u1")
925 db_session.add(repo)
926 await db_session.flush()
927 repo_id = str(repo.repo_id)
928
929 now = datetime.now(tz=timezone.utc)
930 db_session.add(
931 MusehubCommit(
932 commit_id="svc-001",
933 repo_id=repo_id,
934 branch="main",
935 parent_ids=[],
936 message="produce and mix the drop",
937 author="Charlie",
938 timestamp=now,
939 )
940 )
941 await db_session.commit()
942
943 result = await musehub_credits.aggregate_credits(db_session, repo_id, sort="count")
944 assert result.total_contributors == 1
945 assert result.contributors[0].author == "Charlie"
946 assert result.contributors[0].session_count == 1
947
948
949 # ---------------------------------------------------------------------------
950 # Compare endpoint
951 # ---------------------------------------------------------------------------
952
953
954 async def _make_compare_repo(
955 db_session: AsyncSession,
956 client: AsyncClient,
957 auth_headers: StrDict,
958 ) -> str:
959 """Seed a repo with commits on two branches and return repo_id."""
960 from datetime import datetime, timezone
961
962 create = await client.post(
963 "/api/repos",
964 json={"name": "compare-test", "owner": "testuser", "visibility": "private"},
965 headers=auth_headers,
966 )
967 assert create.status_code == 201
968 repo_id: str = str(create.json()["repoId"])
969
970 now = datetime.now(tz=timezone.utc)
971 db_session.add(
972 MusehubCommit(
973 commit_id="base001",
974 repo_id=repo_id,
975 branch="main",
976 parent_ids=[],
977 message="add melody line",
978 author="Alice",
979 timestamp=now,
980 )
981 )
982 db_session.add(
983 MusehubCommit(
984 commit_id="head001",
985 repo_id=repo_id,
986 branch="feature",
987 parent_ids=["base001"],
988 message="add chord progression",
989 author="Bob",
990 timestamp=now,
991 )
992 )
993 await db_session.commit()
994 return repo_id
995
996
997 async def test_compare_radar_data(
998 client: AsyncClient,
999 db_session: AsyncSession,
1000 auth_headers: StrDict,
1001 ) -> None:
1002 """GET /api/repos/{id}/compare returns 5 dimension scores."""
1003 repo_id = await _make_compare_repo(db_session, client, auth_headers)
1004 response = await client.get(
1005 f"/api/repos/{repo_id}/compare?base=main&head=feature",
1006 headers=auth_headers,
1007 )
1008 assert response.status_code == 200
1009 body = response.json()
1010 assert "dimensions" in body
1011 assert len(body["dimensions"]) == 5
1012 expected_dims = {"melodic", "harmonic", "rhythmic", "structural", "dynamic"}
1013 found_dims = {d["dimension"] for d in body["dimensions"]}
1014 assert found_dims == expected_dims
1015 for dim in body["dimensions"]:
1016 assert 0.0 <= dim["score"] <= 1.0
1017 assert dim["level"] in ("NONE", "LOW", "MED", "HIGH")
1018 assert "overallScore" in body
1019 assert 0.0 <= body["overallScore"] <= 1.0
1020
1021
1022 async def test_compare_commit_list(
1023 client: AsyncClient,
1024 db_session: AsyncSession,
1025 auth_headers: StrDict,
1026 ) -> None:
1027 """Commits unique to head are listed in the compare response."""
1028 repo_id = await _make_compare_repo(db_session, client, auth_headers)
1029 response = await client.get(
1030 f"/api/repos/{repo_id}/compare?base=main&head=feature",
1031 headers=auth_headers,
1032 )
1033 assert response.status_code == 200
1034 body = response.json()
1035 assert "commits" in body
1036 # head001 is on feature but not on main
1037 commit_ids = [c["commitId"] for c in body["commits"]]
1038 assert "head001" in commit_ids
1039 # base001 is on main so should NOT appear as unique to head
1040 assert "base001" not in commit_ids
1041
1042
1043 async def test_compare_unknown_ref_422(
1044 client: AsyncClient,
1045 db_session: AsyncSession,
1046 auth_headers: StrDict,
1047 ) -> None:
1048 """Unknown ref (branch with no commits) returns 422."""
1049 create = await client.post(
1050 "/api/repos",
1051 json={"name": "empty-compare", "owner": "testuser", "visibility": "private"},
1052 headers=auth_headers,
1053 )
1054 assert create.status_code == 201
1055 repo_id = create.json()["repoId"]
1056 response = await client.get(
1057 f"/api/repos/{repo_id}/compare?base=nonexistent&head=alsoabsent",
1058 headers=auth_headers,
1059 )
1060 assert response.status_code == 422
1061
1062
1063 async def test_compare_emotion_diff_fields(
1064 client: AsyncClient,
1065 db_session: AsyncSession,
1066 auth_headers: StrDict,
1067 ) -> None:
1068 """Compare response includes emotion diff with required delta fields."""
1069 repo_id = await _make_compare_repo(db_session, client, auth_headers)
1070 response = await client.get(
1071 f"/api/repos/{repo_id}/compare?base=main&head=feature",
1072 headers=auth_headers,
1073 )
1074 assert response.status_code == 200
1075 body = response.json()
1076 assert "emotionDiff" in body
1077 ed = body["emotionDiff"]
1078 for field in ("energyDelta", "valenceDelta", "tensionDelta", "darknessDelta"):
1079 assert field in ed
1080 assert -1.0 <= ed[field] <= 1.0
1081 for field in ("baseEnergy", "headEnergy", "baseValence", "headValence"):
1082 assert field in ed
1083 assert 0.0 <= ed[field] <= 1.0
1084
1085
1086
1087 # ---------------------------------------------------------------------------
1088 # ---------------------------------------------------------------------------
1089 # GET /repos/{repo_id}/settings
1090 # ---------------------------------------------------------------------------
1091
1092 TEST_OWNER_USER_ID = "550e8400-e29b-41d4-a716-446655440000"
1093
1094
1095 async def test_get_repo_settings_returns_defaults(
1096 client: AsyncClient,
1097 db_session: AsyncSession,
1098 auth_headers: StrDict,
1099 ) -> None:
1100 """GET /repos/{repo_id}/settings returns full settings with canonical defaults."""
1101 repo = MusehubRepo(
1102 name="settings-get-test",
1103 owner="testuser",
1104 slug="settings-get-test",
1105 visibility="private",
1106 owner_user_id=TEST_OWNER_USER_ID,
1107 )
1108 db_session.add(repo)
1109 await db_session.commit()
1110 await db_session.refresh(repo)
1111
1112 resp = await client.get(
1113 f"/api/repos/{repo.repo_id}/settings",
1114 headers=auth_headers,
1115 )
1116 assert resp.status_code == 200
1117 body = resp.json()
1118 assert body["name"] == "settings-get-test"
1119 assert body["visibility"] == "private"
1120 assert body["hasIssues"] is True
1121 assert body["allowMergeCommit"] is True
1122 assert body["allowRebaseMerge"] is False
1123 assert body["deleteBranchOnMerge"] is True
1124 assert body["defaultBranch"] == "main"
1125
1126
1127 async def test_get_repo_settings_requires_auth(
1128 client: AsyncClient,
1129 db_session: AsyncSession,
1130 ) -> None:
1131 """GET /repos/{repo_id}/settings returns 401 without a MSign Authorization header."""
1132 repo = MusehubRepo(
1133 name="settings-noauth",
1134 owner="testuser",
1135 slug="settings-noauth",
1136 visibility="private",
1137 owner_user_id=TEST_OWNER_USER_ID,
1138 )
1139 db_session.add(repo)
1140 await db_session.commit()
1141 await db_session.refresh(repo)
1142
1143 resp = await client.get(f"/api/repos/{repo.repo_id}/settings")
1144 assert resp.status_code == 401
1145
1146
1147 async def test_get_repo_settings_returns_403_for_non_admin(
1148 client: AsyncClient,
1149 db_session: AsyncSession,
1150 auth_headers: StrDict,
1151 ) -> None:
1152 """GET /repos/{repo_id}/settings returns 403 when caller is not owner or admin."""
1153 repo = MusehubRepo(
1154 name="settings-403-test",
1155 owner="other-owner",
1156 slug="settings-403-test",
1157 visibility="public",
1158 owner_user_id="other-user-id-not-test",
1159 )
1160 db_session.add(repo)
1161 await db_session.commit()
1162 await db_session.refresh(repo)
1163
1164 resp = await client.get(
1165 f"/api/repos/{repo.repo_id}/settings",
1166 headers=auth_headers,
1167 )
1168 assert resp.status_code == 403
1169
1170
1171 async def test_get_repo_settings_returns_404_for_unknown_repo(
1172 client: AsyncClient,
1173 auth_headers: StrDict,
1174 ) -> None:
1175 """GET /repos/{repo_id}/settings returns 404 for a non-existent repo."""
1176 resp = await client.get(
1177 "/api/repos/nonexistent-repo-id/settings",
1178 headers=auth_headers,
1179 )
1180 assert resp.status_code == 404
1181
1182
1183 # ---------------------------------------------------------------------------
1184 # PATCH /repos/{repo_id}/settings
1185 # ---------------------------------------------------------------------------
1186
1187
1188 async def test_patch_repo_settings_updates_fields(
1189 client: AsyncClient,
1190 db_session: AsyncSession,
1191 auth_headers: StrDict,
1192 ) -> None:
1193 """PATCH /repos/{repo_id}/settings owner can update dedicated and flag fields."""
1194 repo = MusehubRepo(
1195 name="settings-patch-test",
1196 owner="testuser",
1197 slug="settings-patch-test",
1198 visibility="private",
1199 owner_user_id=TEST_OWNER_USER_ID,
1200 )
1201 db_session.add(repo)
1202 await db_session.commit()
1203 await db_session.refresh(repo)
1204
1205 resp = await client.patch(
1206 f"/api/repos/{repo.repo_id}/settings",
1207 json={
1208 "description": "Updated description",
1209 "visibility": "public",
1210 "hasIssues": False,
1211 "allowRebaseMerge": True,
1212 "homepageUrl": "https://muse.app",
1213 "topics": ["classical", "baroque"],
1214 },
1215 headers=auth_headers,
1216 )
1217 assert resp.status_code == 200
1218 body = resp.json()
1219 assert body["description"] == "Updated description"
1220 assert body["visibility"] == "public"
1221 assert body["hasIssues"] is False
1222 assert body["allowRebaseMerge"] is True
1223 assert body["homepageUrl"] == "https://muse.app"
1224 assert body["topics"] == ["classical", "baroque"]
1225 # Untouched field should retain its default
1226 assert body["allowMergeCommit"] is True
1227
1228
1229 async def test_patch_repo_settings_partial_update_preserves_other_fields(
1230 client: AsyncClient,
1231 db_session: AsyncSession,
1232 auth_headers: StrDict,
1233 ) -> None:
1234 """PATCH with a single field leaves all other settings unchanged."""
1235 repo = MusehubRepo(
1236 name="settings-partial-test",
1237 owner="testuser",
1238 slug="settings-partial-test",
1239 visibility="private",
1240 owner_user_id=TEST_OWNER_USER_ID,
1241 )
1242 db_session.add(repo)
1243 await db_session.commit()
1244 await db_session.refresh(repo)
1245
1246 resp = await client.patch(
1247 f"/api/repos/{repo.repo_id}/settings",
1248 json={"defaultBranch": "develop"},
1249 headers=auth_headers,
1250 )
1251 assert resp.status_code == 200
1252 body = resp.json()
1253 assert body["defaultBranch"] == "develop"
1254 # Other fields kept
1255 assert body["name"] == "settings-partial-test"
1256 assert body["visibility"] == "private"
1257 assert body["hasIssues"] is True
1258
1259
1260 async def test_patch_repo_settings_requires_auth(
1261 client: AsyncClient,
1262 db_session: AsyncSession,
1263 ) -> None:
1264 """PATCH /repos/{repo_id}/settings returns 401 without a MSign Authorization header."""
1265 repo = MusehubRepo(
1266 name="settings-patch-noauth",
1267 owner="testuser",
1268 slug="settings-patch-noauth",
1269 visibility="private",
1270 owner_user_id=TEST_OWNER_USER_ID,
1271 )
1272 db_session.add(repo)
1273 await db_session.commit()
1274 await db_session.refresh(repo)
1275
1276 resp = await client.patch(
1277 f"/api/repos/{repo.repo_id}/settings",
1278 json={"visibility": "public"},
1279 )
1280 assert resp.status_code == 401
1281
1282
1283 async def test_patch_repo_settings_returns_403_for_non_admin(
1284 client: AsyncClient,
1285 db_session: AsyncSession,
1286 auth_headers: StrDict,
1287 ) -> None:
1288 """PATCH /repos/{repo_id}/settings returns 403 when caller is not owner or admin."""
1289 repo = MusehubRepo(
1290 name="settings-patch-403",
1291 owner="other-owner",
1292 slug="settings-patch-403",
1293 visibility="public",
1294 owner_user_id="other-user-not-test",
1295 )
1296 db_session.add(repo)
1297 await db_session.commit()
1298 await db_session.refresh(repo)
1299
1300 resp = await client.patch(
1301 f"/api/repos/{repo.repo_id}/settings",
1302 json={"hasWiki": True},
1303 headers=auth_headers,
1304 )
1305 assert resp.status_code == 403
1306
1307
1308 # ---------------------------------------------------------------------------
1309 # DELETE /repos/{repo_id} — soft-delete
1310 # ---------------------------------------------------------------------------
1311
1312
1313 async def test_delete_repo_returns_204(
1314 client: AsyncClient,
1315 auth_headers: StrDict,
1316 ) -> None:
1317 """DELETE /repos/{repo_id} soft-deletes a repo owned by the caller and returns 204."""
1318 create = await client.post(
1319 "/api/repos",
1320 json={"name": "to-delete", "owner": "testuser", "visibility": "private"},
1321 headers=auth_headers,
1322 )
1323 assert create.status_code == 201
1324 repo_id = create.json()["repoId"]
1325
1326 resp = await client.delete(f"/api/repos/{repo_id}", headers=auth_headers)
1327 assert resp.status_code == 204
1328
1329
1330 async def test_delete_repo_hides_repo_from_get(
1331 client: AsyncClient,
1332 auth_headers: StrDict,
1333 ) -> None:
1334 """After DELETE, GET /repos/{repo_id} returns 404."""
1335 create = await client.post(
1336 "/api/repos",
1337 json={"name": "hidden-after-delete", "owner": "testuser", "visibility": "private"},
1338 headers=auth_headers,
1339 )
1340 repo_id = create.json()["repoId"]
1341
1342 await client.delete(f"/api/repos/{repo_id}", headers=auth_headers)
1343
1344 get_resp = await client.get(f"/api/repos/{repo_id}", headers=auth_headers)
1345 assert get_resp.status_code == 404
1346
1347
1348 async def test_delete_repo_requires_auth(
1349 client: AsyncClient,
1350 db_session: AsyncSession,
1351 ) -> None:
1352 """DELETE /repos/{repo_id} returns 401 without a MSign Authorization header."""
1353 repo = MusehubRepo(
1354 name="delete-noauth",
1355 owner="testuser",
1356 slug="delete-noauth",
1357 visibility="public",
1358 owner_user_id=TEST_OWNER_USER_ID,
1359 )
1360 db_session.add(repo)
1361 await db_session.commit()
1362 await db_session.refresh(repo)
1363
1364 resp = await client.delete(f"/api/repos/{repo.repo_id}")
1365 assert resp.status_code == 401
1366
1367
1368 async def test_delete_repo_returns_403_for_non_owner(
1369 client: AsyncClient,
1370 db_session: AsyncSession,
1371 auth_headers: StrDict,
1372 ) -> None:
1373 """DELETE /repos/{repo_id} returns 403 when caller is not the owner."""
1374 repo = MusehubRepo(
1375 name="delete-403",
1376 owner="other-owner",
1377 slug="delete-403",
1378 visibility="public",
1379 owner_user_id="some-other-user-id",
1380 )
1381 db_session.add(repo)
1382 await db_session.commit()
1383 await db_session.refresh(repo)
1384
1385 resp = await client.delete(
1386 f"/api/repos/{repo.repo_id}", headers=auth_headers
1387 )
1388 assert resp.status_code == 403
1389
1390
1391 async def test_delete_repo_returns_404_for_unknown_repo(
1392 client: AsyncClient,
1393 auth_headers: StrDict,
1394 ) -> None:
1395 """DELETE /repos/{repo_id} returns 404 for a non-existent repo."""
1396 resp = await client.delete(
1397 "/api/repos/nonexistent-repo-id", headers=auth_headers
1398 )
1399 assert resp.status_code == 404
1400
1401
1402 async def test_delete_repo_service_hard_deletes_row(
1403 db_session: AsyncSession,
1404 ) -> None:
1405 """delete_repo() service hard-deletes the row from the DB."""
1406 repo = await musehub_repository.create_repo(
1407 db_session,
1408 name="svc-delete-test",
1409 owner="testuser",
1410 visibility="private",
1411 owner_user_id="user-abc",
1412 )
1413 repo_id = repo.repo_id
1414 await db_session.commit()
1415
1416 deleted = await musehub_repository.delete_repo(db_session, repo_id)
1417 await db_session.commit()
1418
1419 assert deleted is True
1420 # Row must be completely gone from the DB
1421 row = await db_session.get(MusehubRepo, repo_id)
1422 assert row is None
1423
1424
1425 async def test_delete_repo_service_returns_false_for_unknown(
1426 db_session: AsyncSession,
1427 ) -> None:
1428 """delete_repo() returns False for a non-existent repo."""
1429 result = await musehub_repository.delete_repo(db_session, "does-not-exist")
1430 assert result is False
1431
1432
1433 # ---------------------------------------------------------------------------
1434 # POST /repos/{repo_id}/transfer — transfer ownership
1435 # ---------------------------------------------------------------------------
1436
1437
1438 async def test_transfer_repo_ownership_returns_200(
1439 client: AsyncClient,
1440 auth_headers: StrDict,
1441 ) -> None:
1442 """POST /repos/{repo_id}/transfer returns 200 with updated ownerUserId."""
1443 create = await client.post(
1444 "/api/repos",
1445 json={"name": "transfer-me", "owner": "testuser", "visibility": "private"},
1446 headers=auth_headers,
1447 )
1448 assert create.status_code == 201
1449 repo_id = create.json()["repoId"]
1450 new_owner = "another-user-uuid-1234"
1451
1452 resp = await client.post(
1453 f"/api/repos/{repo_id}/transfer",
1454 json={"newOwnerUserId": new_owner},
1455 headers=auth_headers,
1456 )
1457 assert resp.status_code == 200
1458 body = resp.json()
1459 assert body["ownerUserId"] == new_owner
1460 assert body["repoId"] == repo_id
1461
1462
1463 async def test_transfer_repo_requires_auth(
1464 client: AsyncClient,
1465 db_session: AsyncSession,
1466 ) -> None:
1467 """POST /repos/{repo_id}/transfer returns 401 without an MSign token."""
1468 repo = MusehubRepo(
1469 name="transfer-noauth",
1470 owner="testuser",
1471 slug="transfer-noauth",
1472 visibility="public",
1473 owner_user_id=TEST_OWNER_USER_ID,
1474 )
1475 db_session.add(repo)
1476 await db_session.commit()
1477 await db_session.refresh(repo)
1478
1479 resp = await client.post(
1480 f"/api/repos/{repo.repo_id}/transfer",
1481 json={"newOwnerUserId": "new-user-id"},
1482 )
1483 assert resp.status_code == 401
1484
1485
1486 # ---------------------------------------------------------------------------
1487 # Wizard creation endpoint — # ---------------------------------------------------------------------------
1488
1489
1490 async def test_create_repo_wizard_initialize_creates_branch_and_commit(
1491 client: AsyncClient,
1492 auth_headers: StrDict,
1493 db_session: AsyncSession,
1494 ) -> None:
1495 """POST /repos with initialize=true creates a default branch + initial commit."""
1496 resp = await client.post(
1497 "/api/repos",
1498 json={
1499 "name": "wizard-init-repo",
1500 "owner": "testuser",
1501 "visibility": "public",
1502 "initialize": True,
1503 "defaultBranch": "main",
1504 },
1505 headers=auth_headers,
1506 )
1507 assert resp.status_code == 201
1508 repo_id = resp.json()["repoId"]
1509
1510 branches_resp = await client.get(
1511 f"/api/repos/{repo_id}/branches",
1512 headers=auth_headers,
1513 )
1514 assert branches_resp.status_code == 200
1515 branches = branches_resp.json()["branches"]
1516 assert any(b["name"] == "main" for b in branches), "Expected 'main' branch to be created"
1517
1518 commits_resp = await client.get(
1519 f"/api/repos/{repo_id}/commits",
1520 headers=auth_headers,
1521 )
1522 assert commits_resp.status_code == 200
1523 commits = commits_resp.json()["commits"]
1524 assert len(commits) == 1
1525 assert commits[0]["message"] == "Initial commit"
1526
1527
1528 async def test_create_repo_wizard_no_initialize_stays_empty(
1529 client: AsyncClient,
1530 auth_headers: StrDict,
1531 ) -> None:
1532 """POST /repos with initialize=false leaves branches and commits empty."""
1533 resp = await client.post(
1534 "/api/repos",
1535 json={
1536 "name": "wizard-noinit-repo",
1537 "owner": "testuser",
1538 "initialize": False,
1539 },
1540 headers=auth_headers,
1541 )
1542 assert resp.status_code == 201
1543 repo_id = resp.json()["repoId"]
1544
1545 branches_resp = await client.get(
1546 f"/api/repos/{repo_id}/branches",
1547 headers=auth_headers,
1548 )
1549 assert branches_resp.json()["branches"] == []
1550
1551 commits_resp = await client.get(
1552 f"/api/repos/{repo_id}/commits",
1553 headers=auth_headers,
1554 )
1555 assert commits_resp.json()["commits"] == []
1556
1557
1558 async def test_create_repo_wizard_topics_merged_into_tags(
1559 client: AsyncClient,
1560 auth_headers: StrDict,
1561 ) -> None:
1562 """POST /repos with topics merges them into the tag list (deduplicated)."""
1563 resp = await client.post(
1564 "/api/repos",
1565 json={
1566 "name": "topics-test-repo",
1567 "owner": "testuser",
1568 "tags": ["jazz"],
1569 "topics": ["classical", "jazz"], # 'jazz' deduped
1570 "initialize": False,
1571 },
1572 headers=auth_headers,
1573 )
1574 assert resp.status_code == 201
1575 body = resp.json()
1576 tags: list[str] = body["tags"]
1577 assert "jazz" in tags
1578 assert "classical" in tags
1579 assert tags.count("jazz") == 1, "Duplicate 'jazz' must be removed"
1580
1581
1582 async def test_create_repo_wizard_clone_url_uses_https_scheme(
1583 client: AsyncClient,
1584 auth_headers: StrDict,
1585 ) -> None:
1586 """Clone URL returned by POST /repos uses an http(s):// scheme, not musehub://.
1587
1588 musehub:// is not a scheme the muse CLI understands. The clone URL must be
1589 a valid HTTP(S) URL so that `muse clone <url>` works without --hub.
1590 """
1591 resp = await client.post(
1592 "/api/repos",
1593 json={"name": "clone-url-test", "owner": "testuser", "initialize": False},
1594 headers=auth_headers,
1595 )
1596 assert resp.status_code == 201
1597 clone_url: str = resp.json()["cloneUrl"]
1598 assert clone_url.startswith("http"), f"Expected http(s):// prefix, got: {clone_url}"
1599 assert "musehub://" not in clone_url, "musehub:// is not a valid CLI scheme"
1600 assert "testuser" in clone_url
1601
1602
1603 async def test_create_repo_wizard_template_copies_description(
1604 client: AsyncClient,
1605 auth_headers: StrDict,
1606 db_session: AsyncSession,
1607 ) -> None:
1608 """POST /repos with template_repo_id copies description from a public template."""
1609 template = MusehubRepo(
1610 name="template-source",
1611 owner="template-owner",
1612 slug="template-source",
1613 visibility="public",
1614 owner_user_id="template-owner-id",
1615 description="A great neo-baroque composition template",
1616 tags=["baroque", "piano"],
1617 )
1618 db_session.add(template)
1619 await db_session.commit()
1620 await db_session.refresh(template)
1621 template_id = str(template.repo_id)
1622
1623 resp = await client.post(
1624 "/api/repos",
1625 json={
1626 "name": "from-template-repo",
1627 "owner": "testuser",
1628 "initialize": False,
1629 "templateRepoId": template_id,
1630 },
1631 headers=auth_headers,
1632 )
1633 assert resp.status_code == 201
1634 body = resp.json()
1635 assert body["description"] == "A great neo-baroque composition template"
1636 assert "baroque" in body["tags"]
1637 assert "piano" in body["tags"]
1638
1639
1640 async def test_create_repo_wizard_private_template_not_copied(
1641 client: AsyncClient,
1642 auth_headers: StrDict,
1643 db_session: AsyncSession,
1644 ) -> None:
1645 """Private template repo metadata is NOT copied (must be public)."""
1646 private_template = MusehubRepo(
1647 name="private-template",
1648 owner="secret-owner",
1649 slug="private-template",
1650 visibility="private",
1651 owner_user_id="secret-id",
1652 description="Secret description",
1653 tags=["secret"],
1654 )
1655 db_session.add(private_template)
1656 await db_session.commit()
1657 await db_session.refresh(private_template)
1658 template_id = str(private_template.repo_id)
1659
1660 resp = await client.post(
1661 "/api/repos",
1662 json={
1663 "name": "refused-template-repo",
1664 "owner": "testuser",
1665 "description": "My own description",
1666 "initialize": False,
1667 "templateRepoId": template_id,
1668 },
1669 headers=auth_headers,
1670 )
1671 assert resp.status_code == 201
1672 body = resp.json()
1673 # Private template must not override user's own description
1674 assert body["description"] == "My own description"
1675 assert "secret" not in body["tags"]
1676
1677
1678 async def test_create_repo_wizard_custom_default_branch(
1679 client: AsyncClient,
1680 auth_headers: StrDict,
1681 ) -> None:
1682 """POST /repos with initialize=true and custom defaultBranch creates the right branch."""
1683 resp = await client.post(
1684 "/api/repos",
1685 json={
1686 "name": "custom-branch-repo",
1687 "owner": "testuser",
1688 "initialize": True,
1689 "defaultBranch": "develop",
1690 },
1691 headers=auth_headers,
1692 )
1693 assert resp.status_code == 201
1694 repo_id = resp.json()["repoId"]
1695
1696 branches_resp = await client.get(
1697 f"/api/repos/{repo_id}/branches",
1698 headers=auth_headers,
1699 )
1700 branch_names = [b["name"] for b in branches_resp.json()["branches"]]
1701 assert "develop" in branch_names
1702 assert "main" not in branch_names
1703
1704
1705 # ---------------------------------------------------------------------------
1706 # GET /repos — list repos for authenticated user
1707 # ---------------------------------------------------------------------------
1708
1709
1710 async def test_list_my_repos_returns_owned_repos(
1711 client: AsyncClient,
1712 auth_headers: StrDict,
1713 ) -> None:
1714 """GET /repos returns repos created by the authenticated user."""
1715 # Create two repos
1716 for name in ("owned-repo-a", "owned-repo-b"):
1717 await client.post(
1718 "/api/repos",
1719 json={"name": name, "owner": "testuser", "initialize": False},
1720 headers=auth_headers,
1721 )
1722
1723 resp = await client.get("/api/repos", headers=auth_headers)
1724 assert resp.status_code == 200
1725 body = resp.json()
1726 assert "repos" in body
1727 assert "total" in body
1728 assert "nextCursor" in body
1729 names = [r["name"] for r in body["repos"]]
1730 assert "owned-repo-a" in names
1731 assert "owned-repo-b" in names
1732
1733
1734 async def test_list_my_repos_requires_auth(client: AsyncClient) -> None:
1735 """GET /repos returns 401 without an MSign token."""
1736 resp = await client.get("/api/repos")
1737 assert resp.status_code == 401
1738
1739
1740 async def test_transfer_repo_returns_403_for_non_owner(
1741 client: AsyncClient,
1742 db_session: AsyncSession,
1743 auth_headers: StrDict,
1744 ) -> None:
1745 """POST /repos/{repo_id}/transfer returns 403 when caller is not the owner."""
1746 repo = MusehubRepo(
1747 name="transfer-403",
1748 owner="other-owner",
1749 slug="transfer-403",
1750 visibility="public",
1751 owner_user_id="some-other-user-id",
1752 )
1753 db_session.add(repo)
1754 await db_session.commit()
1755 await db_session.refresh(repo)
1756
1757 resp = await client.post(
1758 f"/api/repos/{repo.repo_id}/transfer",
1759 json={"newOwnerUserId": "attacker-user-id"},
1760 headers=auth_headers,
1761 )
1762 assert resp.status_code == 403
1763
1764
1765 async def test_transfer_repo_returns_404_for_unknown_repo(
1766 client: AsyncClient,
1767 auth_headers: StrDict,
1768 ) -> None:
1769 """POST /repos/{repo_id}/transfer returns 404 for a non-existent repo."""
1770 resp = await client.post(
1771 "/api/repos/nonexistent-repo-id/transfer",
1772 json={"newOwnerUserId": "some-user"},
1773 headers=auth_headers,
1774 )
1775 assert resp.status_code == 404
1776
1777
1778 async def test_transfer_repo_service_updates_owner_user_id(
1779 db_session: AsyncSession,
1780 ) -> None:
1781 """transfer_repo_ownership() service updates owner_user_id on the row."""
1782 repo = await musehub_repository.create_repo(
1783 db_session,
1784 name="svc-transfer-test",
1785 owner="testuser",
1786 visibility="private",
1787 owner_user_id="original-owner-id",
1788 )
1789 await db_session.commit()
1790
1791 updated = await musehub_repository.transfer_repo_ownership(
1792 db_session, repo.repo_id, "new-owner-id"
1793 )
1794 await db_session.commit()
1795
1796 assert updated is not None
1797 assert updated.owner_user_id == "new-owner-id"
1798 # Verify persisted
1799 fetched = await musehub_repository.get_repo(db_session, repo.repo_id)
1800 assert fetched is not None
1801 assert fetched.owner_user_id == "new-owner-id"
1802
1803
1804 async def test_transfer_repo_service_returns_none_for_unknown(
1805 db_session: AsyncSession,
1806 ) -> None:
1807 """transfer_repo_ownership() returns None for a non-existent repo."""
1808 result = await musehub_repository.transfer_repo_ownership(
1809 db_session, "does-not-exist", "new-owner"
1810 )
1811 assert result is None
1812
1813
1814 # ---------------------------------------------------------------------------
1815 # GET /repos — list repos for authenticated user
1816 # ---------------------------------------------------------------------------
1817
1818
1819 async def test_list_my_repos_total_matches_count(
1820 client: AsyncClient,
1821 auth_headers: StrDict,
1822 ) -> None:
1823 """total field in GET /repos matches the number of repos created."""
1824 initial = await client.get("/api/repos", headers=auth_headers)
1825 initial_total: int = initial.json()["total"]
1826
1827 await client.post(
1828 "/api/repos",
1829 json={"name": "total-count-test", "owner": "testuser", "initialize": False},
1830 headers=auth_headers,
1831 )
1832
1833 resp = await client.get("/api/repos", headers=auth_headers)
1834 assert resp.status_code == 200
1835 assert resp.json()["total"] == initial_total + 1
1836
1837
1838 async def test_list_my_repos_pagination_cursor(
1839 client: AsyncClient,
1840 auth_headers: StrDict,
1841 db_session: AsyncSession,
1842 ) -> None:
1843 """GET /repos with limit=1 returns a nextCursor that fetches the next page."""
1844 from datetime import datetime, timedelta, timezone
1845
1846 owner_user_id = "550e8400-e29b-41d4-a716-446655440000"
1847 now = datetime.now(tz=timezone.utc)
1848 for i in range(3):
1849 repo = MusehubRepo(
1850 name=f"paged-repo-{i}",
1851 owner="testuser",
1852 slug=f"paged-repo-{i}",
1853 visibility="public",
1854 owner_user_id=owner_user_id,
1855 )
1856 repo.created_at = now - timedelta(seconds=i)
1857 db_session.add(repo)
1858 await db_session.commit()
1859
1860 first_page = await client.get(
1861 "/api/repos?limit=1",
1862 headers=auth_headers,
1863 )
1864 assert first_page.status_code == 200
1865 body = first_page.json()
1866 assert len(body["repos"]) == 1
1867 next_cursor = body["nextCursor"]
1868 assert next_cursor is not None
1869
1870 second_page = await client.get(
1871 f"/api/repos?limit=1&cursor={next_cursor}",
1872 headers=auth_headers,
1873 )
1874 assert second_page.status_code == 200
1875 second_body = second_page.json()
1876 assert len(second_body["repos"]) == 1
1877 # Pages must not overlap
1878 first_id = body["repos"][0]["repoId"]
1879 second_id = second_body["repos"][0]["repoId"]
1880 assert first_id != second_id
1881
1882
1883 async def test_list_my_repos_service_direct(db_session: AsyncSession) -> None:
1884 """list_repos_for_user() returns only repos owned by the given user."""
1885 from musehub.services.musehub_repository import list_repos_for_user
1886
1887 owner_handle = "user-list-direct"
1888 other_handle = "user-other-direct"
1889
1890 repo_mine = MusehubRepo(
1891 name="mine-direct",
1892 owner=owner_handle,
1893 slug="mine-direct",
1894 visibility="private",
1895 owner_user_id="uid-list-direct",
1896 )
1897 repo_other = MusehubRepo(
1898 name="not-mine-direct",
1899 owner=other_handle,
1900 slug="not-mine-direct",
1901 visibility="private",
1902 owner_user_id="uid-other-direct",
1903 )
1904 db_session.add_all([repo_mine, repo_other])
1905 await db_session.commit()
1906
1907 result = await list_repos_for_user(db_session, owner_handle)
1908 repo_ids = {r.repo_id for r in result.repos}
1909 assert str(repo_mine.repo_id) in repo_ids
1910 assert str(repo_other.repo_id) not in repo_ids
1911
1912
1913 # ---------------------------------------------------------------------------
1914 # GET /repos/{repo_id}/collaborators/{username}/permission
1915 # ---------------------------------------------------------------------------
1916
1917
1918 async def test_collab_access_owner_returns_owner_permission(
1919 client: AsyncClient,
1920 db_session: AsyncSession,
1921 auth_headers: StrDict,
1922 ) -> None:
1923 """Owner's username returns permission='owner' with accepted_at=null."""
1924 from musehub.db.musehub_collaborator_models import MusehubCollaborator
1925
1926 owner_id = TEST_OWNER_USER_ID
1927 repo = MusehubRepo(
1928 name="access-owner-test",
1929 owner="testuser",
1930 slug="access-owner-test",
1931 visibility="private",
1932 owner_user_id=owner_id,
1933 )
1934 db_session.add(repo)
1935 await db_session.commit()
1936 await db_session.refresh(repo)
1937
1938 resp = await client.get(
1939 f"/api/repos/{repo.repo_id}/collaborators/{owner_id}/permission",
1940 headers=auth_headers,
1941 )
1942 assert resp.status_code == 200
1943 body = resp.json()
1944 assert body["username"] == owner_id
1945 assert body["permission"] == "owner"
1946 assert body["acceptedAt"] is None
1947
1948
1949 async def test_collab_access_collaborator_returns_permission(
1950 client: AsyncClient,
1951 db_session: AsyncSession,
1952 auth_headers: StrDict,
1953 ) -> None:
1954 """A known collaborator returns their permission level and accepted_at."""
1955 from datetime import datetime, timezone
1956
1957 from musehub.db.musehub_collaborator_models import MusehubCollaborator
1958
1959 owner_id = TEST_OWNER_USER_ID
1960 collab_user_id = "collab-user-write"
1961
1962 repo = MusehubRepo(
1963 name="access-collab-test",
1964 owner="testuser",
1965 slug="access-collab-test",
1966 visibility="private",
1967 owner_user_id=owner_id,
1968 )
1969 db_session.add(repo)
1970 await db_session.commit()
1971 await db_session.refresh(repo)
1972
1973 accepted = datetime(2026, 1, 10, 10, 0, 0, tzinfo=timezone.utc)
1974 collab = MusehubCollaborator(
1975 repo_id=str(repo.repo_id),
1976 identity_handle=collab_user_id,
1977 permission="write",
1978 accepted_at=accepted,
1979 )
1980 db_session.add(collab)
1981 await db_session.commit()
1982
1983 resp = await client.get(
1984 f"/api/repos/{repo.repo_id}/collaborators/{collab_user_id}/permission",
1985 headers=auth_headers,
1986 )
1987 assert resp.status_code == 200
1988 body = resp.json()
1989 assert body["username"] == collab_user_id
1990 assert body["permission"] == "write"
1991 assert body["acceptedAt"] is not None
1992
1993
1994 async def test_collab_access_non_collaborator_returns_404(
1995 client: AsyncClient,
1996 db_session: AsyncSession,
1997 auth_headers: StrDict,
1998 ) -> None:
1999 """A user who is not a collaborator returns 404 with an informative message."""
2000 repo = MusehubRepo(
2001 name="access-404-test",
2002 owner="testuser",
2003 slug="access-404-test",
2004 visibility="private",
2005 owner_user_id=TEST_OWNER_USER_ID,
2006 )
2007 db_session.add(repo)
2008 await db_session.commit()
2009 await db_session.refresh(repo)
2010
2011 stranger = "total-stranger-user"
2012 resp = await client.get(
2013 f"/api/repos/{repo.repo_id}/collaborators/{stranger}/permission",
2014 headers=auth_headers,
2015 )
2016 assert resp.status_code == 404
2017 assert stranger in resp.json()["detail"]
2018
2019
2020 async def test_collab_access_unknown_repo_returns_404(
2021 client: AsyncClient,
2022 auth_headers: StrDict,
2023 ) -> None:
2024 """Querying an unknown repo_id returns 404."""
2025 resp = await client.get(
2026 "/api/repos/nonexistent-repo/collaborators/anyone/permission",
2027 headers=auth_headers,
2028 )
2029 assert resp.status_code == 404
2030
2031
2032 async def test_collab_access_requires_auth(
2033 client: AsyncClient,
2034 db_session: AsyncSession,
2035 ) -> None:
2036 """GET /collaborators/{username}/permission returns 401 without an MSign token."""
2037 repo = MusehubRepo(
2038 name="access-auth-test",
2039 owner="testuser",
2040 slug="access-auth-test",
2041 visibility="public",
2042 owner_user_id=TEST_OWNER_USER_ID,
2043 )
2044 db_session.add(repo)
2045 await db_session.commit()
2046 await db_session.refresh(repo)
2047
2048 resp = await client.get(
2049 f"/api/repos/{repo.repo_id}/collaborators/anyone/permission"
2050 )
2051 assert resp.status_code == 401
2052
2053
2054 async def test_collab_access_admin_permission(
2055 client: AsyncClient,
2056 db_session: AsyncSession,
2057 auth_headers: StrDict,
2058 ) -> None:
2059 """A collaborator with admin permission returns permission='admin'."""
2060 from musehub.db.musehub_collaborator_models import MusehubCollaborator
2061
2062 repo = MusehubRepo(
2063 name="access-admin-test",
2064 owner="testuser",
2065 slug="access-admin-test",
2066 visibility="private",
2067 owner_user_id=TEST_OWNER_USER_ID,
2068 )
2069 db_session.add(repo)
2070 await db_session.commit()
2071 await db_session.refresh(repo)
2072
2073 admin_user = "admin-collab-user"
2074 collab = MusehubCollaborator(
2075 repo_id=str(repo.repo_id),
2076 identity_handle=admin_user,
2077 permission="admin",
2078 accepted_at=None,
2079 )
2080 db_session.add(collab)
2081 await db_session.commit()
2082
2083 resp = await client.get(
2084 f"/api/repos/{repo.repo_id}/collaborators/{admin_user}/permission",
2085 headers=auth_headers,
2086 )
2087 assert resp.status_code == 200
2088 body = resp.json()
2089 assert body["permission"] == "admin"
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago