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