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