gabriel / musehub public
test_musehub_forks.py python
809 lines 26.5 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Tests for the fork-a-repo feature.
2
3 Covers:
4 POST /api/repos/{repo_id}/fork
5 - Happy path: fork a public repo
6 - 404 when source repo does not exist
7 - 403 when source repo is private
8 - 403 when caller tries to fork their own repo
9 - 409 when caller has already forked the same repo
10 - 401 when unauthenticated
11 - Optional name / description / visibility fields
12
13 GET /api/repos/{repo_id}/forks
14 - Returns empty list when no forks exist
15 - Returns all direct forks with source attribution
16 - 404 when source repo does not exist
17 - Public endpoint (no auth required)
18
19 GET /api/repos/{repo_id}/fork-network
20 - Returns root node with children
21 - Total_forks count is correct
22 - Public endpoint (no auth required)
23
24 GET /api/users/{username}/forks
25 - Returns empty list when user has no forks
26 - Returns forks with source attribution after forking
27 - 404 when username does not exist
28
29 Service layer
30 - fork_repo raises ValueError for business rule violations
31 - get_user_forks returns real data after forks are created
32 - list_repo_forks_flat returns real data after forks are created
33
34 All tests use the shared ``client``, ``auth_headers``, ``test_user``, and
35 ``db_session`` fixtures from conftest.py.
36 """
37 from __future__ import annotations
38
39 from datetime import datetime, timezone
40
41 import pytest
42 from httpx import AsyncClient
43 from sqlalchemy.ext.asyncio import AsyncSession
44
45 from musehub.core.genesis import compute_fork_id, compute_identity_id, compute_repo_id
46 from musehub.db.musehub_identity_models import MusehubIdentity
47 from musehub.db.musehub_repo_models import MusehubRepo
48 from musehub.types.json_types import StrDict
49
50
51 # ---------------------------------------------------------------------------
52 # Helpers
53 # ---------------------------------------------------------------------------
54
55 _TEST_HANDLE = "testuser" # matches conftest._TEST_HANDLE
56
57
58 async def _create_public_repo(
59 client: AsyncClient,
60 auth_headers: StrDict,
61 name: str = "upstream-beats",
62 ) -> str:
63 """Create a public repo via the API and return its repo_id."""
64 resp = await client.post(
65 "/api/repos",
66 json={"name": name, "owner": _TEST_HANDLE, "visibility": "public", "initialize": False},
67 headers=auth_headers,
68 )
69 assert resp.status_code == 201, resp.text
70 return str(resp.json()["repoId"])
71
72
73 async def _create_private_repo(
74 client: AsyncClient,
75 auth_headers: StrDict,
76 name: str = "secret-project",
77 ) -> str:
78 """Create a private repo via the API and return its repo_id."""
79 resp = await client.post(
80 "/api/repos",
81 json={"name": name, "owner": _TEST_HANDLE, "visibility": "private", "initialize": False},
82 headers=auth_headers,
83 )
84 assert resp.status_code == 201, resp.text
85 return str(resp.json()["repoId"])
86
87
88 async def _seed_identity(db: AsyncSession, handle: str) -> MusehubIdentity:
89 """Seed a secondary identity in the DB (simulating a different user)."""
90 identity = MusehubIdentity(
91 identity_id=compute_identity_id(handle.encode()),
92 handle=handle,
93 display_name=handle.title(),
94 identity_type="human",
95 )
96 db.add(identity)
97 await db.commit()
98 await db.refresh(identity)
99 return identity
100
101
102 async def _seed_source_repo(
103 db: AsyncSession,
104 owner: str,
105 slug: str,
106 visibility: str = "public",
107 **kwargs: str | list[str],
108 ) -> str:
109 """Seed owner identity + source repo; return repo_id string."""
110 await _seed_identity(db, owner)
111 created_at = datetime.now(tz=timezone.utc)
112 owner_id = compute_identity_id(owner.encode())
113 repo = MusehubRepo(
114 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
115 name=slug,
116 owner=owner,
117 slug=slug,
118 visibility=visibility,
119 owner_user_id=owner_id,
120 created_at=created_at,
121 updated_at=created_at,
122 **kwargs,
123 )
124 db.add(repo)
125 await db.commit()
126 await db.refresh(repo)
127 return str(repo.repo_id)
128
129
130 # ---------------------------------------------------------------------------
131 # POST /api/repos/{repo_id}/fork — happy path
132 # ---------------------------------------------------------------------------
133
134
135 async def test_fork_public_repo_returns_201(
136 client: AsyncClient,
137 auth_headers: StrDict,
138 db_session: AsyncSession,
139 ) -> None:
140 """Forking a public repo returns 201 with fork metadata."""
141 # Seed a public source repo owned by a different identity so the caller
142 # can fork it.
143 source_id = await _seed_source_repo(db_session, "alice", "shared-beats", description="Alice's public beats")
144
145 resp = await client.post(
146 f"/api/repos/{source_id}/fork",
147 json={},
148 headers=auth_headers,
149 )
150
151 assert resp.status_code == 201, resp.text
152 body = resp.json()
153
154 assert "forkId" in body
155 assert body["sourceOwner"] == "alice"
156 assert body["sourceSlug"] == "shared-beats"
157 assert "forkRepo" in body
158 fork_repo = body["forkRepo"]
159 assert fork_repo["owner"] == _TEST_HANDLE
160 assert fork_repo["visibility"] == "public"
161 assert "forkedAt" in body
162
163
164 async def test_fork_sets_description_with_attribution(
165 client: AsyncClient,
166 auth_headers: StrDict,
167 db_session: AsyncSession,
168 ) -> None:
169 """Fork description defaults to 'Fork of {owner}/{slug}: {source description}'."""
170 source_id = await _seed_source_repo(db_session, "bob", "groove-box", description="Bob's groove box")
171
172 resp = await client.post(
173 f"/api/repos/{source_id}/fork",
174 json={},
175 headers=auth_headers,
176 )
177
178 assert resp.status_code == 201, resp.text
179 description = resp.json()["forkRepo"]["description"]
180 assert "bob" in description
181 assert "groove-box" in description
182 assert "Bob's groove box" in description
183
184
185 async def test_fork_with_custom_name(
186 client: AsyncClient,
187 auth_headers: StrDict,
188 db_session: AsyncSession,
189 ) -> None:
190 """Fork accepts an optional custom name for the new repo."""
191 source_id = await _seed_source_repo(db_session, "carol", "jazz-trio", description="Carol's jazz trio")
192
193 resp = await client.post(
194 f"/api/repos/{source_id}/fork",
195 json={"name": "my-jazz-experiment"},
196 headers=auth_headers,
197 )
198
199 assert resp.status_code == 201, resp.text
200 fork_repo = resp.json()["forkRepo"]
201 assert "jazz-experiment" in fork_repo["slug"]
202
203
204 async def test_fork_with_private_visibility(
205 client: AsyncClient,
206 auth_headers: StrDict,
207 db_session: AsyncSession,
208 ) -> None:
209 """Fork accepts visibility='private' to create a private fork."""
210 source_id = await _seed_source_repo(db_session, "dave", "open-source-beats", description="Dave's open source beats")
211
212 resp = await client.post(
213 f"/api/repos/{source_id}/fork",
214 json={"visibility": "private"},
215 headers=auth_headers,
216 )
217
218 assert resp.status_code == 201, resp.text
219 assert resp.json()["forkRepo"]["visibility"] == "private"
220
221
222 async def test_fork_with_custom_description(
223 client: AsyncClient,
224 auth_headers: StrDict,
225 db_session: AsyncSession,
226 ) -> None:
227 """Fork accepts a custom description that overrides the default attribution."""
228 source_id = await _seed_source_repo(db_session, "eve", "synth-lab", description="")
229
230 resp = await client.post(
231 f"/api/repos/{source_id}/fork",
232 json={"description": "My custom synth fork"},
233 headers=auth_headers,
234 )
235
236 assert resp.status_code == 201, resp.text
237 assert resp.json()["forkRepo"]["description"] == "My custom synth fork"
238
239
240 # ---------------------------------------------------------------------------
241 # POST /api/repos/{repo_id}/fork — error cases
242 # ---------------------------------------------------------------------------
243
244
245 async def test_fork_nonexistent_repo_returns_404(
246 client: AsyncClient,
247 auth_headers: StrDict,
248 ) -> None:
249 """Forking a repo that doesn't exist returns 404."""
250 resp = await client.post(
251 "/api/repos/00000000-0000-0000-0000-000000000000/fork",
252 json={},
253 headers=auth_headers,
254 )
255 assert resp.status_code == 404
256
257
258 async def test_fork_private_repo_returns_403(
259 client: AsyncClient,
260 auth_headers: StrDict,
261 db_session: AsyncSession,
262 ) -> None:
263 """Forking a private repo returns 403."""
264 source_id = await _seed_source_repo(db_session, "frank", "private-session", visibility="private", description="Frank's private work")
265
266 resp = await client.post(
267 f"/api/repos/{source_id}/fork",
268 json={},
269 headers=auth_headers,
270 )
271 assert resp.status_code == 403
272 assert "public" in resp.json()["detail"].lower()
273
274
275 async def test_fork_own_repo_returns_403(
276 client: AsyncClient,
277 auth_headers: StrDict,
278 db_session: AsyncSession,
279 ) -> None:
280 """Caller cannot fork a repository they already own — returns 403."""
281 source_id = await _create_public_repo(client, auth_headers, name="my-own-beats")
282
283 resp = await client.post(
284 f"/api/repos/{source_id}/fork",
285 json={},
286 headers=auth_headers,
287 )
288 assert resp.status_code == 403
289 assert "own" in resp.json()["detail"].lower()
290
291
292 async def test_fork_same_repo_twice_returns_409(
293 client: AsyncClient,
294 auth_headers: StrDict,
295 db_session: AsyncSession,
296 ) -> None:
297 """Forking the same repo twice returns 409 Conflict."""
298 source_id = await _seed_source_repo(db_session, "grace", "shared-vibes", description="Grace's vibes")
299
300 # First fork succeeds
301 resp1 = await client.post(
302 f"/api/repos/{source_id}/fork",
303 json={},
304 headers=auth_headers,
305 )
306 assert resp1.status_code == 201, resp1.text
307
308 # Second fork of the same repo → 409
309 resp2 = await client.post(
310 f"/api/repos/{source_id}/fork",
311 json={"name": "another-fork"},
312 headers=auth_headers,
313 )
314 assert resp2.status_code == 409
315
316
317 async def test_fork_requires_auth(client: AsyncClient, db_session: AsyncSession) -> None:
318 """Unauthenticated fork request returns 401."""
319 source_id = await _seed_source_repo(db_session, "henry", "open-beats", description="")
320
321 resp = await client.post(f"/api/repos/{source_id}/fork", json={})
322 assert resp.status_code == 401
323
324
325 # ---------------------------------------------------------------------------
326 # GET /api/repos/{repo_id}/forks
327 # ---------------------------------------------------------------------------
328
329
330 async def test_list_forks_empty_when_no_forks(
331 client: AsyncClient,
332 auth_headers: StrDict,
333 db_session: AsyncSession,
334 ) -> None:
335 """A repo with no forks returns an empty list."""
336 source_id = await _seed_source_repo(db_session, "iris", "unfork-able", description="")
337
338 resp = await client.get(f"/api/repos/{source_id}/forks")
339 assert resp.status_code == 200
340 body = resp.json()
341 assert body["forks"] == []
342 assert body["total"] == 0
343
344
345 async def test_list_forks_shows_fork_after_creation(
346 client: AsyncClient,
347 auth_headers: StrDict,
348 db_session: AsyncSession,
349 ) -> None:
350 """A fork appears in the list after being created."""
351 source_id = await _seed_source_repo(db_session, "jack", "popular-track", description="Jack's popular track")
352
353 # Fork it
354 fork_resp = await client.post(
355 f"/api/repos/{source_id}/fork",
356 json={},
357 headers=auth_headers,
358 )
359 assert fork_resp.status_code == 201, fork_resp.text
360
361 # List forks
362 resp = await client.get(f"/api/repos/{source_id}/forks")
363 assert resp.status_code == 200
364 body = resp.json()
365
366 assert body["total"] == 1
367 fork = body["forks"][0]
368 assert fork["sourceOwner"] == "jack"
369 assert fork["sourceSlug"] == "popular-track"
370 assert fork["forkRepo"]["owner"] == _TEST_HANDLE
371
372
373 async def test_list_forks_no_auth_required(
374 client: AsyncClient,
375 db_session: AsyncSession,
376 ) -> None:
377 """List forks endpoint is publicly accessible without authentication."""
378 source_id = await _seed_source_repo(db_session, "kate", "public-beats", description="")
379
380 # No auth_headers passed
381 resp = await client.get(f"/api/repos/{source_id}/forks")
382 assert resp.status_code == 200
383
384
385 async def test_list_forks_returns_404_for_missing_repo(
386 client: AsyncClient,
387 ) -> None:
388 """List forks for a non-existent repo returns 404."""
389 resp = await client.get("/api/repos/00000000-0000-0000-0000-000000000000/forks")
390 assert resp.status_code == 404
391
392
393 # ---------------------------------------------------------------------------
394 # GET /api/repos/{repo_id}/fork-network
395 # ---------------------------------------------------------------------------
396
397
398 async def test_fork_network_has_root_and_children(
399 client: AsyncClient,
400 auth_headers: StrDict,
401 db_session: AsyncSession,
402 ) -> None:
403 """Fork network returns root with forked repo as a child."""
404 source_id = await _seed_source_repo(db_session, "liam", "groove-machine", description="Liam's groove machine")
405
406 # Fork it
407 fork_resp = await client.post(
408 f"/api/repos/{source_id}/fork",
409 json={},
410 headers=auth_headers,
411 )
412 assert fork_resp.status_code == 201, fork_resp.text
413
414 # Get fork network
415 resp = await client.get(f"/api/repos/{source_id}/fork-network")
416 assert resp.status_code == 200
417 body = resp.json()
418
419 assert "root" in body
420 assert body["totalForks"] == 1
421 root = body["root"]
422 assert root["owner"] == "liam"
423 assert root["repoSlug"] == "groove-machine"
424 assert len(root["children"]) == 1
425 child = root["children"][0]
426 assert child["owner"] == _TEST_HANDLE
427 assert child["forkedBy"] == _TEST_HANDLE
428
429
430 async def test_fork_network_empty_children_when_no_forks(
431 client: AsyncClient,
432 db_session: AsyncSession,
433 ) -> None:
434 """Fork network for a repo with no forks has an empty children list."""
435 source_id = await _seed_source_repo(db_session, "mia", "solo-track", description="")
436
437 resp = await client.get(f"/api/repos/{source_id}/fork-network")
438 assert resp.status_code == 200
439 body = resp.json()
440 assert body["totalForks"] == 0
441 assert body["root"]["children"] == []
442
443
444 async def test_fork_network_returns_404_for_missing_repo(
445 client: AsyncClient,
446 ) -> None:
447 """Fork network for a non-existent repo returns 404."""
448 resp = await client.get("/api/repos/00000000-0000-0000-0000-000000000000/fork-network")
449 assert resp.status_code == 404
450
451
452 async def test_fork_network_no_auth_required(
453 client: AsyncClient,
454 db_session: AsyncSession,
455 ) -> None:
456 """Fork network endpoint is publicly accessible without authentication."""
457 source_id = await _seed_source_repo(db_session, "noah", "collab-beats", description="")
458
459 resp = await client.get(f"/api/repos/{source_id}/fork-network")
460 assert resp.status_code == 200
461
462
463 # ---------------------------------------------------------------------------
464 # GET /api/users/{username}/forks
465 # ---------------------------------------------------------------------------
466
467
468 async def test_get_user_forks_empty_for_new_user(
469 client: AsyncClient,
470 test_user: MusehubIdentity,
471 ) -> None:
472 """A user with no forks returns an empty list."""
473 resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
474 assert resp.status_code == 200
475 body = resp.json()
476 assert body["forks"] == []
477 assert body["total"] == 0
478
479
480 async def test_get_user_forks_shows_fork_after_creation(
481 client: AsyncClient,
482 auth_headers: StrDict,
483 db_session: AsyncSession,
484 test_user: MusehubIdentity,
485 ) -> None:
486 """User's forks list is populated after forking a repo."""
487 source_id = await _seed_source_repo(db_session, "olivia", "soul-session", description="Olivia's soul session")
488
489 # Fork it
490 fork_resp = await client.post(
491 f"/api/repos/{source_id}/fork",
492 json={},
493 headers=auth_headers,
494 )
495 assert fork_resp.status_code == 201, fork_resp.text
496
497 # Get user forks
498 resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
499 assert resp.status_code == 200
500 body = resp.json()
501
502 assert body["total"] == 1
503 entry = body["forks"][0]
504 assert entry["sourceOwner"] == "olivia"
505 assert entry["sourceSlug"] == "soul-session"
506 assert entry["forkRepo"]["owner"] == _TEST_HANDLE
507 assert "forkId" in entry
508 assert "forkedAt" in entry
509
510
511 async def test_get_user_forks_404_for_unknown_user(
512 client: AsyncClient,
513 ) -> None:
514 """Requesting forks for an unknown user returns 404."""
515 resp = await client.get("/api/users/nonexistent-user-xyz/forks")
516 assert resp.status_code == 404
517
518
519 async def test_get_user_forks_no_auth_required(
520 client: AsyncClient,
521 test_user: MusehubIdentity,
522 ) -> None:
523 """User forks endpoint is publicly accessible without authentication."""
524 resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
525 assert resp.status_code == 200
526
527
528 # ---------------------------------------------------------------------------
529 # Fork response shape
530 # ---------------------------------------------------------------------------
531
532
533 async def test_fork_response_contains_all_required_fields(
534 client: AsyncClient,
535 auth_headers: StrDict,
536 db_session: AsyncSession,
537 ) -> None:
538 """Fork creation response contains all documented fields."""
539 source_id = await _seed_source_repo(db_session, "peter", "field-check-beats", description="Peter's beats", tags=["jazz", "soul"])
540
541 resp = await client.post(
542 f"/api/repos/{source_id}/fork",
543 json={},
544 headers=auth_headers,
545 )
546 assert resp.status_code == 201, resp.text
547 body = resp.json()
548
549 # Top-level fork entry fields
550 for field in ("forkId", "forkRepo", "sourceOwner", "sourceSlug", "forkedAt"):
551 assert field in body, f"Missing field: {field}"
552
553 # Fork repo fields
554 fork_repo = body["forkRepo"]
555 for field in ("repoId", "name", "owner", "slug", "visibility", "description", "tags", "createdAt"):
556 assert field in fork_repo, f"Missing forkRepo field: {field}"
557
558 # Tags are copied from source
559 assert "jazz" in fork_repo["tags"]
560 assert "soul" in fork_repo["tags"]
561
562
563 # ---------------------------------------------------------------------------
564 # Multiple forks of the same source
565 # ---------------------------------------------------------------------------
566
567
568 async def test_multiple_forks_appear_in_list(
569 client: AsyncClient,
570 auth_headers: StrDict,
571 db_session: AsyncSession,
572 ) -> None:
573 """Multiple forks by different users all appear in the source repo's fork list."""
574 source_id = await _seed_source_repo(db_session, "quinn", "viral-track", description="Quinn's viral track")
575
576 # testuser forks it
577 fork_resp = await client.post(
578 f"/api/repos/{source_id}/fork",
579 json={},
580 headers=auth_headers,
581 )
582 assert fork_resp.status_code == 201, fork_resp.text
583
584 # Seed a second forker via direct DB insert (bypasses auth)
585 _rachel_id = compute_identity_id(b"rachel")
586 await _seed_identity(db_session, "rachel")
587 _fr2_created = datetime.now(tz=timezone.utc)
588 _fr2_id = compute_repo_id(_rachel_id, "viral-track", "code", _fr2_created.isoformat())
589 fork_repo_2 = MusehubRepo(
590 repo_id=_fr2_id,
591 name="viral-track",
592 owner="rachel",
593 slug="viral-track",
594 visibility="public",
595 owner_user_id=_rachel_id,
596 description="Fork of quinn/viral-track: Quinn's viral track",
597 created_at=_fr2_created,
598 updated_at=_fr2_created,
599 )
600 db_session.add(fork_repo_2)
601 await db_session.commit()
602 await db_session.refresh(fork_repo_2)
603
604 from musehub.db.musehub_social_models import MusehubFork
605 _fork_now = datetime.now(tz=timezone.utc)
606 fork_record = MusehubFork(
607 fork_id=compute_fork_id(source_id, _fr2_id, _fork_now.isoformat()),
608 source_repo_id=source_id,
609 fork_repo_id=fork_repo_2.repo_id,
610 forked_by="rachel",
611 )
612 db_session.add(fork_record)
613 await db_session.commit()
614
615 resp = await client.get(f"/api/repos/{source_id}/forks")
616 assert resp.status_code == 200
617 body = resp.json()
618
619 assert body["total"] == 2
620 owners = {f["forkRepo"]["owner"] for f in body["forks"]}
621 assert _TEST_HANDLE in owners
622 assert "rachel" in owners
623
624
625 # ---------------------------------------------------------------------------
626 # Private fork visibility — security hardening
627 # ---------------------------------------------------------------------------
628
629
630 async def test_private_fork_hidden_from_source_forks_list(
631 client: AsyncClient,
632 auth_headers: StrDict,
633 db_session: AsyncSession,
634 ) -> None:
635 """A private fork must NOT appear in the public GET /repos/{id}/forks list."""
636 source_id = await _seed_source_repo(db_session, "sam", "secret-upstream", description="Sam's upstream")
637
638 # Fork with private visibility
639 resp = await client.post(
640 f"/api/repos/{source_id}/fork",
641 json={"visibility": "private"},
642 headers=auth_headers,
643 )
644 assert resp.status_code == 201, resp.text
645
646 # Public listing must be empty — the fork is private
647 list_resp = await client.get(f"/api/repos/{source_id}/forks")
648 assert list_resp.status_code == 200
649 body = list_resp.json()
650 assert body["total"] == 0
651 assert body["forks"] == []
652
653
654 async def test_private_fork_hidden_from_fork_network(
655 client: AsyncClient,
656 auth_headers: StrDict,
657 db_session: AsyncSession,
658 ) -> None:
659 """A private fork must NOT appear in the public fork-network tree."""
660 source_id = await _seed_source_repo(db_session, "tara", "silent-upstream", description="Tara's upstream")
661
662 # Fork with private visibility
663 resp = await client.post(
664 f"/api/repos/{source_id}/fork",
665 json={"visibility": "private"},
666 headers=auth_headers,
667 )
668 assert resp.status_code == 201, resp.text
669
670 # Fork network must show 0 forks — private fork is not in tree
671 net_resp = await client.get(f"/api/repos/{source_id}/fork-network")
672 assert net_resp.status_code == 200
673 body = net_resp.json()
674 assert body["totalForks"] == 0
675 assert body["root"]["children"] == []
676
677
678 async def test_private_fork_hidden_from_public_user_forks(
679 client: AsyncClient,
680 test_user: MusehubIdentity,
681 db_session: AsyncSession,
682 ) -> None:
683 """A private fork must NOT appear when an unauthenticated caller views a user's forks.
684
685 Note: this test does NOT request the ``auth_headers`` fixture because that
686 fixture globally overrides ``optional_signed_request`` to return the test
687 context, making every request in the test look authenticated. Instead we
688 seed the fork directly in the DB so we can make a genuinely anonymous call.
689 """
690 _uma_id = compute_identity_id(b"uma")
691 _test_id = compute_identity_id(_TEST_HANDLE.encode())
692 await _seed_identity(db_session, "uma")
693 _src_ts = datetime.now(tz=timezone.utc)
694 _src_id = compute_repo_id(_uma_id, "covert-upstream", "code", _src_ts.isoformat())
695 source = MusehubRepo(
696 repo_id=_src_id,
697 name="covert-upstream",
698 owner="uma",
699 slug="covert-upstream",
700 visibility="public",
701 owner_user_id=_uma_id,
702 description="Uma's upstream",
703 created_at=_src_ts,
704 updated_at=_src_ts,
705 )
706 _frk_ts = datetime.now(tz=timezone.utc)
707 _frk_id = compute_repo_id(_test_id, "covert-upstream", "code", _frk_ts.isoformat())
708 fork_repo = MusehubRepo(
709 repo_id=_frk_id,
710 name="covert-upstream",
711 owner=_TEST_HANDLE,
712 slug="covert-upstream",
713 visibility="private", # private fork
714 owner_user_id=_test_id,
715 description="Fork of uma/covert-upstream: Uma's upstream",
716 created_at=_frk_ts,
717 updated_at=_frk_ts,
718 )
719 db_session.add(source)
720 db_session.add(fork_repo)
721 await db_session.commit()
722 await db_session.refresh(source)
723 await db_session.refresh(fork_repo)
724
725 from musehub.db.musehub_social_models import MusehubFork
726 _fk_ts = datetime.now(tz=timezone.utc)
727 fork_record = MusehubFork(
728 fork_id=compute_fork_id(_src_id, _frk_id, _fk_ts.isoformat()),
729 source_repo_id=str(source.repo_id),
730 fork_repo_id=str(fork_repo.repo_id),
731 forked_by=_TEST_HANDLE,
732 )
733 db_session.add(fork_record)
734 await db_session.commit()
735
736 # Genuinely unauthenticated GET — no auth_headers fixture, no dep override
737 anon_resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
738 assert anon_resp.status_code == 200
739 body = anon_resp.json()
740 assert body["total"] == 0
741 assert body["forks"] == []
742
743
744 async def test_private_fork_visible_to_owner(
745 client: AsyncClient,
746 auth_headers: StrDict,
747 db_session: AsyncSession,
748 ) -> None:
749 """The fork owner can see their own private fork via the authenticated forks endpoint."""
750 source_id = await _seed_source_repo(db_session, "vera", "owner-visible-upstream", description="Vera's upstream")
751
752 # Fork with private visibility
753 resp = await client.post(
754 f"/api/repos/{source_id}/fork",
755 json={"visibility": "private"},
756 headers=auth_headers,
757 )
758 assert resp.status_code == 201, resp.text
759
760 # Authenticated as owner — private fork IS visible
761 auth_resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks", headers=auth_headers)
762 assert auth_resp.status_code == 200
763 body = auth_resp.json()
764 assert body["total"] == 1
765 assert body["forks"][0]["forkRepo"]["visibility"] == "private"
766
767
768 async def test_invalid_visibility_returns_422(
769 client: AsyncClient,
770 auth_headers: StrDict,
771 db_session: AsyncSession,
772 ) -> None:
773 """Fork request with invalid visibility value returns 422 Unprocessable Entity."""
774 source_id = await _seed_source_repo(db_session, "walter", "valid-upstream", description="Walter's upstream")
775
776 resp = await client.post(
777 f"/api/repos/{source_id}/fork",
778 json={"visibility": "superadmin"},
779 headers=auth_headers,
780 )
781 assert resp.status_code == 422
782
783
784 async def test_slug_collision_auto_resolved(
785 client: AsyncClient,
786 auth_headers: StrDict,
787 db_session: AsyncSession,
788 ) -> None:
789 """Forking when the caller already owns a repo with the same name auto-suffixes the slug."""
790 # testuser already owns a repo with the same name as the source
791 existing_resp = await client.post(
792 "/api/repos",
793 json={"name": "classic-track", "owner": _TEST_HANDLE, "visibility": "public", "initialize": False},
794 headers=auth_headers,
795 )
796 assert existing_resp.status_code == 201, existing_resp.text
797
798 source_id = await _seed_source_repo(db_session, "xavier", "classic-track", description="Xavier's classic track")
799
800 # Fork should succeed despite slug collision — gets auto-suffixed slug
801 fork_resp = await client.post(
802 f"/api/repos/{source_id}/fork",
803 json={},
804 headers=auth_headers,
805 )
806 assert fork_resp.status_code == 201, fork_resp.text
807 fork_slug = fork_resp.json()["forkRepo"]["slug"]
808 # Slug must differ from the existing one (auto-suffixed)
809 assert fork_slug == "classic-track-2"
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago